# Design a Notification Service: Decide Once, Store First, Nudge With an ID (2026)

> A notification service looks like calling a push API and is really a pipeline: decide who should get what, store a record first, deliver through unreliable providers with retries and expiry, and keep a campaign from delaying a login code. The prompt, the numbers, and the design.

*Source: https://www.infowok.com/design-a-notification-service/ · Sukhveer Kaur · Published October 24, 2026*

---

An online shop ships an order. One line of code calls a push API: "Your order is on its way." In testing it works every time.

Then the real world arrives. The push provider takes two seconds to answer, and the order service waits with it. The provider is down for ten minutes, and orders fail. A retry fires twice, and a customer's phone buzzes twice. Imagine a marketing blast to millions of people, with a login code stuck behind it for half an hour.

None of that is a bug in the line of code. It is the missing system around it: **decide once, store first, then nudge the device with an id.**

<Prerequisites>

- The [interview framework](/system-design-interview-framework-2026/) and [estimation primer](/back-of-envelope-estimation-primer/).
- [Message delivery guarantees](/message-delivery-guarantees-explained/), [design a rate limiter](/design-a-rate-limiter/) and [design a chat system](/design-a-chat-system/), because this problem reuses all three.

</Prerequisites>

<Callout type="note" title="Remember from the chat lesson">
Why does a push only wake a device instead of carrying the message? Because the push can miss, so the durable record is the truth. A notification is a nudge, not the delivery.
</Callout>

<KeyTakeaways>

- A notification service is a pipeline: decide, store, queue, deliver, retry and expire, not a single API call.
- Never call the push provider inside the user's request: store the notification and deliver it from a queue.
- Retries are unavoidable, so every notification needs an id and a duplicate check.
- Give notifications an expiry, because a late "your driver arrived" is worse than none.
- Keep urgent notifications in a separate lane from campaigns, so bulk sends cannot delay a login code.

</KeyTakeaways>

<LessonOutcome>

Design a notification service in 45 minutes: rules and duplicate checks in one place, store before delivering, retry safely with expiry, remove dead tokens, and keep urgent messages ahead of campaigns.

</LessonOutcome>

## Step 1: Pin Down the Problem

**Functional requirements:**

1. Services publish events ("order shipped", "new message", "login code").
2. The service decides who is notified, on which channel (push, email, SMS, in-app), and when.
3. Users can set preferences, mute things, and see a notification history.
4. A campaign can be sent to millions of users.

**Out of scope** (say these out loud):

- Writing the message copy (an event carries a template id, a language and data, and the worker renders it), email deliverability internals, SMS carrier setup, and open-rate analytics.
- The chat system's own delivery path. Here the question is how a device is woken.

**Ask first**, then state your assumptions aloud: is this one company's events or a platform for other teams? Which messages are transactional (a login code) and which are marketing? How fast must a code arrive? Which regions? I will assume at-least-once delivery plus a duplicate check, because no push provider offers more.

**Non-functional requirements**, each with a number. These are assumptions you announce:

- Fast for urgent messages: a login code should arrive in seconds.
- Reliable: an accepted notification is delivered, or expires on purpose, without duplicates in the common case.
- Scale: assume 10 million daily users. Step 4 turns that into numbers.
- Lawful: opt-outs and quiet hours are enforced in code, not by convention (see the table under step 5, first dive).

## Step 2: Entities and API

```text
Event         id, type, user, data
Preference    user, type, channels
Notification  id, user, type,
              status, expires_at
Device        user, token, os

POST /events
  Idempotency-Key: <event id>
  -> 202 accepted
  -> 401/403 caller not allowed
  -> 409 same key, other body
  -> 429 caller over quota
GET  /notifications?cursor=
  -> items + next_cursor
PUT  /preferences
POST /devices
  -> register a token
```

The service takes **events** in and makes **notifications** out. The `id` of a notification is built from the event, so the same event always makes the same id. That one choice powers the duplicate check later, and it also makes `POST /events` safe for a caller to retry. History uses a cursor, not an offset, because new items keep arriving at the top of a newest-first list and an offset would repeat or skip rows.

A user has several devices, so `Device` is a list, and `POST /devices` runs at each app start because tokens change. `status` moves from pending to sent to delivered, or ends failed or expired.

## Step 3: A Design That Works

Start with the hook's line of code: the order service calls the push provider inside the request.

![First working design, send inline: the order service calls the push provider during the request, so a slow or failing provider slows or fails the order, and at the assumed peak about 23,200 requests wait on it at once](./design-a-notification-service-v1.svg)

It works in a demo, and many candidates stop here.

<Callout type="tip" title="Predict first">
Assume 11,600 events a second at peak and a provider that answers in 2 seconds. How many requests are waiting on the provider at the same moment?
</Callout>

Call it **23,200 requests in flight** (11,600 × 2 seconds), each holding a thread, with no retries, record or rules. The fix is a queue in front of the provider, which brings the rest of the design with it.

![The pipeline this lesson builds: an event is decided, stored as an inbox record, queued per channel, sent by workers that retry and expire, passed to push, email and SMS providers, and the device is nudged and then fetches](./design-a-notification-service-pipeline.svg)

## Step 4: Estimate What Matters

```text
Assume 10M daily users
20 notifications each a day

Events: 200M / 86,400
  ≈ 2,300/s
Peak (x5): ≈ 11,600/s
Inbox: 200M x 300 B
  = 60 GB a day
  ≈ 1.8 TB for 30 days
```

Then the campaign:

```text
Campaign: 10M users
Lane speed: 5,000/s (assumed)
10,000,000 / 5,000
  = 2,000 s ≈ 33 minutes
```

Each line decides something:

- **11,600 events a second** at peak is modest for a queue. The hard part is correctness and isolation.
- **1.8 TB for 30 days** makes the inbox a real data store with a retention rule. Partition it by user, because every read is one user's history.
- A **campaign takes about 33 minutes**, and anything urgent in its lane waits that long.
- The 5,000 a second is an assumption: half of FCM's [default quota of 600,000 messages a minute per project](https://firebase.google.com/docs/cloud-messaging/throttling-and-quotas) (10,000 a second, as of October 2026), leaving room for urgent traffic. Quotas change.

![Assumed load numbers for the notification service: 10 million daily users, about 2,300 events a second and 11,600 at peak, 23,200 requests in flight for an inline two-second call, 60 GB of inbox data a day, and a 10 million user campaign taking about 33 minutes at 5,000 a second](./design-a-notification-service-scale.svg)

## Step 5: Deep Dives

Four questions decide this design, and interviewers usually pick two or three: delivery and retries, lanes, and duplicates. Start with the first and let them steer. The production half (failures, regions, caller auth, compliance, follow-ups) has its own lesson: [Failure Modes, Security and Follow-Ups](/design-a-notification-service-follow-ups/).

### Who Should Be Notified, and When?

<Concept href="/design-a-rate-limiter/" title="Design a rate limiter">
Caps per user are a rate limiter: a token bucket per user and type decides when one more notification is too many.
</Concept>

<DesignOption tier="works" title="Notify everyone the event touches">
Every event produces a notification for every affected user, on every channel.

<Tradeoff>Blocked users get pinged, muted threads buzz, and a busy day floods people until they turn notifications off for good.</Tradeoff>
</DesignOption>

<DesignOption tier="scales" title="Rules in a fixed order">
Check rules in one fixed order and stop at the first that decides. Matrix does this with push rules, which are [checked by kind in a fixed order](https://github.com/matrix-org/matrix-spec/blob/main/content/client-server-api/modules/push.md): override, content, room, sender, then underride. Mastodon's [notify service](https://github.com/mastodon/mastodon/blob/main/app/services/notify_service.rb) first drops a notification if the sender is blocked or muted or the conversation is muted.

<Tradeoff>The order is a product decision, and a wrong one is a bug users notice, such as a mute that does not beat a keyword rule.</Tradeoff>
</DesignOption>

<DesignOption tier="survives" title="Decide once, centrally, with caps and a duplicate check">
Put the rules, the per-user caps, the quiet hours and the duplicate check in one decide step that every channel trusts. Caps use a token bucket per user and type. The duplicate check uses the notification id: Matrix says [duplicate suppression using the event id](https://github.com/matrix-org/matrix-spec/blob/main/data/api/push-gateway/push_notifier.yaml) is needed so that retries do not cause duplicate alerts, while updating an unread count is idempotent and needs none.

<Tradeoff>One more service on the path, and a read-heavy dependency. Keep it fast and cached, but clear the cache on opt-out, because an opt-out must win at once.</Tradeoff>
</DesignOption>

![Deciding who gets a notification: checks run in a fixed order, drop if blocked or muted, apply quiet hours and caps, pick channels, skip duplicates by id, save filtered ones without pushing, then store and queue](./design-a-notification-service-decide.svg)

In Mastodon's code, a notification that fails a policy is **saved and marked filtered**, not dropped: no push, but history stays honest. The order of the six steps is design judgement, apart from the sourced parts.

#### Quiet Hours and Scheduled Sends

A notification that should wait is saved with a send-at time in the user's time zone, and a scheduler queues it when due, so nothing sleeps inside a worker.

#### Opt-Out and Consent

Keep transactional and marketing as separate types with separate opt-outs, so turning off promotions never blocks a login code, and log every opt-in and opt-out. The legal rules that shape this (send hours, unsubscribe deadlines) are in the follow-ups lesson.

### Why Store Before You Deliver?

<Concept href="/message-delivery-guarantees-explained/" title="Message delivery guarantees">
A queue gives at-least-once delivery. Whatever consumes it must cope with seeing the same job twice, and must be able to find the facts again.
</Concept>

<DesignOption tier="works" title="Queue a job with everything in it">
Put the whole message on a queue and let a worker send it.

<Tradeoff>If the job is lost, the notification vanishes with it: no history, nothing to retry from.</Tradeoff>
</DesignOption>

<DesignOption tier="scales" title="Save a record, then queue its id">
Write the notification to the inbox first, then queue a job that carries only its id. Mastodon's service [saves the notification and only then pushes](https://github.com/mastodon/mastodon/blob/main/app/services/notify_service.rb).

<Tradeoff>Two writes. If the second fails, the notification is stored but never sent, so sweep for records stuck in "pending" and re-queue them.</Tradeoff>
</DesignOption>

<DesignOption tier="survives" title="The worker re-checks the record before sending">
When the job runs, the worker loads the record and asks whether it should still go. Mastodon's [push worker](https://github.com/mastodon/mastodon/blob/main/app/workers/web/push_notification_worker.rb) returns early if the notification is older than its 48-hour TTL and checks that the activity it points to still exists, because it could have been deleted in the meantime.

<Tradeoff>A load per send, in exchange for a deleted post or expired offer quietly stopping.</Tradeoff>
</DesignOption>

As in the chat lesson, the inbox is the truth and the push says "go and look".

### How Do You Deliver Through an Unreliable Provider?

<Concept href="/event-driven-architecture/" title="Event-driven architecture">
Delivery is a consumer of events, and the provider is an external service that fails on its own schedule.
</Concept>

Your service does not reach the phone. Matrix's [push gateway design](https://github.com/matrix-org/matrix-spec/blob/main/content/push-gateway-api.md) shows the path: the server calls a push gateway, the gateway submits to a push provider, and the provider reaches the device. The spec's example names APNS and GCM; GCM is the old name for what Google [replaced with Firebase Cloud Messaging](https://firebase.blog/posts/2018/04/time-to-upgrade-from-gcm-to-fcm/) (FCM).

#### What Push Providers Promise

- Apple calls APNs [a best-effort service](https://developer.apple.com/documentation/usernotifications/sending-notification-requests-to-apns) that may reorder notifications. For an offline device it stores only one per app, usually the latest, for up to 30 days depending on `apns-expiration`.
- FCM keeps an undelivered message for [four weeks by default](https://firebase.google.com/docs/cloud-messaging/customize-messages/setting-message-lifespan) unless you set a `ttl`, so "your driver arrived" can land weeks late.
- Payloads are small: [4 KB on APNs](https://developer.apple.com/documentation/usernotifications/sending-notification-requests-to-apns) (5 KB for VoIP) and [4,096 bytes on FCM](https://firebase.google.com/docs/reference/fcm/rest/v1/ErrorCode). So send an id and let the app fetch the rest.
- A collapse id ([`apns-collapse-id`](https://developer.apple.com/documentation/usernotifications/sending-notification-requests-to-apns), at most 64 bytes) merges repeats into one visible notification, so "3 new messages" replaces two older alerts instead of stacking.

**The provider is a best-effort pipe, so the inbox, not the push, is the record of what the user should see.**

#### Three Ways to Deliver

<DesignOption tier="works" title="One try, in the request">
Call the provider once, as in step 3.

<Tradeoff>A slow provider slows your service, and a failed call is lost.</Tradeoff>
</DesignOption>

<DesignOption tier="scales" title="Workers with retries and backoff">
Workers send from the queue and retry with growing waits. Matrix says that on an HTTP error the server [should retry for a reasonable time with exponential backoff](https://github.com/matrix-org/matrix-spec/blob/main/content/push-gateway-api.md); Mastodon's worker uses a dedicated queue with five retries. Five tries that double from 30 seconds wait 30 + 60 + 120 + 240 + 480 = 930 seconds, about 15 minutes.

When the tries run out, mark the record failed and park the job in a dead-letter queue.

<Tradeoff>Retries multiply load on a struggling provider. Add jitter, honour a `Retry-After` header, cap the tries, and stop when the notification expires. FCM asks for exactly this on a [503 UNAVAILABLE](https://firebase.google.com/docs/reference/fcm/rest/v1/ErrorCode), and for a wait of at least a minute before retrying a quota error.</Tradeoff>
</DesignOption>

<DesignOption tier="survives" title="Classify the answer, expire, and clean up tokens">
Not every failure deserves a retry. Mastodon's web push worker [removes a subscription](https://github.com/mastodon/mastodon/blob/main/app/workers/web/push_notification_worker.rb) on a 4xx other than 408 and 429, and raises on other failures so the job is retried.

Mobile providers need a finer rule:

- **Delete** on APNs [410, token no longer active](https://developer.apple.com/documentation/usernotifications/handling-notification-responses-from-apns), and FCM [404 UNREGISTERED](https://firebase.google.com/docs/cloud-messaging/manage-tokens).
- **Alert, do not delete,** on FCM 400 INVALID_ARGUMENT (a bad token only when the payload is valid) and APNs 403 (your own credentials failing).
- **Prune by age.** FCM [considers a registration stale after a month without connecting](https://firebase.google.com/docs/cloud-messaging/manage-tokens) by default and expires an inactive Android one after 270 days, so save a last-seen time whenever the app uploads its token.

Set an expiry per type: the [Web Push standard](https://www.rfc-editor.org/rfc/rfc8030) requires a TTL and forbids delivery after it. When a channel reports back later, as Twilio's [status callbacks](https://www.twilio.com/docs/messaging/guides/track-outbound-message-status) do, store the result on the same record.

<Tradeoff>A wrong classification either hammers a dead token or deletes a good one. Log every removal, and treat a burst as a sign something upstream broke. For a login code, a failed push can fall back to SMS.</Tradeoff>
</DesignOption>

![What a worker does with the provider answer: success is marked sent, a timeout, rate limit or server error retries with growing waits until expiry, an unregistered-token answer (410 or 404) removes the token, and a bad payload or credentials error raises an alert](./design-a-notification-service-deliver.svg)

#### The iOS Nudge-With-an-Id Pattern

iOS limits background work, so ntfy's documentation says [instant notifications on iOS need a central server](https://github.com/binwiederhier/ntfy/blob/main/docs/config.md). A self-hosted server forwards a "poll request" holding only the message id through Firebase and APNS, and the phone fetches the message from the original server. Without that forward, delivery can take hours, or 20 to 30 minutes if the phone is in use.

### How Do You Keep Campaigns From Blocking Urgent Messages?

<Concept href="/message-delivery-guarantees-explained/" title="Message delivery guarantees">
A queue is first in, first out. Without priorities, a pile of promotions in front of a login code means the code waits for all of them.
</Concept>

<DesignOption tier="works" title="One queue for everything">
All notifications share a queue and are sent in order.

<Tradeoff>A campaign of 10 million delays every urgent message behind it by the whole campaign, about 33 minutes at the assumed rate.</Tradeoff>
</DesignOption>

<DesignOption tier="scales" title="Priority lanes">
Give urgent types, such as login codes and security alerts, their own queue and workers, and keep campaigns in a bulk lane. ntfy has [five priorities, from min to max](https://github.com/binwiederhier/ntfy/blob/main/docs/publish.md), and Matrix lets a notification be marked `high` or `low`, so a gateway can send less urgent ones [in a way that preserves battery power](https://github.com/matrix-org/matrix-spec/blob/main/data/api/push-gateway/push_notifier.yaml).

<Tradeoff>More queues to watch. If everything is urgent, the lanes mean nothing.</Tradeoff>
</DesignOption>

<DesignOption tier="survives" title="Throttled campaigns that expire">
Send the bulk lane at a steady rate below the provider's limit, spread across time zones, with a short TTL so a message that missed its moment is dropped. ntfy lets a publisher [delay delivery by 10 seconds up to 3 days](https://github.com/binwiederhier/ntfy/blob/main/docs/publish.md).

<Tradeoff>A campaign takes minutes, and too low a throttle makes a timed offer arrive late.</Tradeoff>
</DesignOption>

![Two lanes: urgent notifications such as login codes use a small fast lane with a short expiry, while a campaign to 10 million users uses a throttled bulk lane at 5,000 a second taking about 33 minutes, so it cannot delay a code](./design-a-notification-service-lanes.svg)

<Callout type="key" title="A rule to remember">
**Decide once, store first, nudge with an id.** The inbox record makes every retry safe; the device fetches the rest.
</Callout>

## The Final Design in One Picture

![Final design: services publish events, a decide step applies rules and caps and skips duplicates, the inbox stores each notification, an urgent lane and a bulk lane feed channel workers that retry and expire, workers send through providers, and devices receive an id and fetch the rest](./design-a-notification-service-final.svg)

One notification, start to finish:

1. The order service publishes "order shipped" and returns at once.
2. The decide step checks blocks, preferences, quiet hours, the cap and the id, then drops, filters or passes it.
3. The notification is saved in the inbox, then a job carrying only its id joins the urgent or bulk lane.
4. A worker loads the record, checks it has not expired, and sends it to the provider. Success marks it sent; transient errors retry until expiry; a dead token is removed.
5. The device gets a small push with an id and fetches the content, or reads the inbox on opening the app.

## Production Depth Lives in the Follow-Ups Lesson

The working design is complete for the round. These three topics are in [Failure Modes, Security and Follow-Ups](/design-a-notification-service-follow-ups/).

| Topic | The question it answers |
|---|---|
| Failure table and monitoring | If this part dies, what happens, and what do you watch? |
| Two regions and caller auth | Where does it run, and who may publish? |
| Compliance and follow-ups | Which rules limit sends, and what comes after the main design? |

## What Each Level Should Show

| Level | Expected in the room |
|---|---|
| Mid-level | A queue and workers, channels, a provider. May need a hint about retries and duplicates. |
| Senior | An inbox before delivery, an id-based duplicate check, retries with backoff and expiry, dead-token cleanup, a separate lane for urgent messages. |
| Staff+ | A central decide step with caps and quiet hours, filtered versus dropped, sweeps for stuck records, campaign throttling, provider limits and which errors delete a token, a home region per user with declared failover, caller auth and quotas, opt-out and quiet-hour rules, a failure table, and what to build first. |

## Red Flags and Green Flags

<AnswerCompare>
<Weak label="Red flag">
"On each event, call FCM and APNs." No queue, no duplicate handling, nothing about a provider being slow or a campaign sharing the path.
</Weak>
<Strong label="Green flag">
"I'll decide once in a central step, store the notification before sending, and deliver from per-lane queues with retries, an expiry and dead-token cleanup. Urgent messages get their own lane, so a campaign cannot delay a login code."
</Strong>
</AnswerCompare>

<Callout type="warning" title="The most common way to lose this round">
Describing the happy path and stopping. The interviewer is waiting for "what if it is down, retries twice, or there are millions of them?".
</Callout>

## Try It: A Flash Sale Alert

<Exercise title="A 2 million user alert, and a login code" time="15 min">

Change one variable. A flash sale sends an alert to **2 million users**. Assume the bulk lane sends 4,000 a second and a login code arrives while it runs.

1. How long does the alert take to finish?
2. What happens to the login code if they share a lane?
3. How do you change the design?

<Hint>

Divide users by the send rate. Then ask what a first-in, first-out queue does to something that arrives late.

</Hint>

<Expected title="One reasonable answer">

```text
2,000,000 / 4,000
  = 500 s (8.3 min)
```

The alert takes about eight minutes, and a code arriving mid-way waits behind the rest if they share a lane.

Give codes their own lane and short expiry. Keep the sale in the throttled bulk lane with a TTL shorter than the sale.

</Expected>

</Exercise>

## Check Yourself

<CheckYourself>

<Question q="Why save the notification before queueing the delivery?">

The record is the truth. A lost job can be found and retried from it; a self-contained job disappears with its message.

</Question>

<Question q="A retry fires after the provider already accepted the first try. How do you avoid a double buzz?">

Derive an id from the event, check it before sending, and send it so the device can drop a repeat. Matrix calls for this duplicate suppression because retries are expected.

</Question>

<Question q="APNs answers 410 for one token, 429 for another, and 403 for a third. What do you do with each?">

The 410 means the token is no longer active, so delete it. The 429 means too many requests to that token, so wait and retry. The 403 is a problem with your certificate or signing token, so keep every device token and alert a human. All three are on [Apple's response list](https://developer.apple.com/documentation/usernotifications/handling-notification-responses-from-apns).

</Question>

</CheckYourself>

## In One Picture

```text
Requirements
  events in, notifications out
Decide
  rules, caps, duplicate id
Store
  inbox before any push
Deliver
  queue, retry, expire, clean
Lanes
  urgent apart from campaigns
Rule
  decide once, store first,
  nudge with an id
```

## Conclusion

A notification service is a pipeline wrapped around an unreliable edge. A durable record with a small nudge on top also runs through chat and feeds.

**Which would you tackle first: duplicates, expiry or lanes?** Tell us in the comments.

<NextSteps>

- **The delivery path to a device:** [design a chat system](/design-a-chat-system/) and [message delivery guarantees](/message-delivery-guarantees-explained/).
- **Caps per user:** [design a rate limiter](/design-a-rate-limiter/).
- **Next in Module 4:** [design a file storage service](/design-a-file-storage-service/), where large uploads must survive a dropped connection.

</NextSteps>
