InfoWok

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.

SK
Sukhveer Kaur
Published October 24, 2026
12 min read
Open in ChatGPT Open in Claude
Notification service, system design problem 5: an event passes through rules into an inbox and then to a device, beside the line decide, store, nudgeSoftware Architecture
DESIGN A NOTIFICATION SERVICE
On this page +

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.

Intermediate40 min
Before you start
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.

Key takeaways
  • 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.
After this lesson you can

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.

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.

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

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?

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.

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 (10,000 a second, as of October 2026), leaving room for urgent traffic. Quotas change.

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.

Who Should Be Notified, and When?#

Concept in use: 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.

Works · Notify everyone the event touchescorrect, simplest

Every event produces a notification for every affected user, on every channel.

Trade-offBlocked users get pinged, muted threads buzz, and a busy day floods people until they turn notifications off for good.
Scales · Rules in a fixed orderhandles the load

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: override, content, room, sender, then underride. Mastodon’s notify service first drops a notification if the sender is blocked or muted or the conversation is muted.

Trade-offThe 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.
Survives · Decide once, centrally, with caps and a duplicate checkhandles failure and growth

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 is needed so that retries do not cause duplicate alerts, while updating an unread count is idempotent and needs none.

Trade-offOne 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.

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.

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 in use: 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.

Works · Queue a job with everything in itcorrect, simplest

Put the whole message on a queue and let a worker send it.

Trade-offIf the job is lost, the notification vanishes with it: no history, nothing to retry from.
Scales · Save a record, then queue its idhandles the load

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.

Trade-offTwo writes. If the second fails, the notification is stored but never sent, so sweep for records stuck in “pending” and re-queue them.
Survives · The worker re-checks the record before sendinghandles failure and growth

When the job runs, the worker loads the record and asks whether it should still go. Mastodon’s push worker 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.

Trade-offA load per send, in exchange for a deleted post or expired offer quietly stopping.

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 in use: Event-driven architecture

Delivery is a consumer of events, and the provider is an external service that fails on its own schedule.

Your service does not reach the phone. Matrix’s push gateway design 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 (FCM).

What Push Providers Promise#

  • Apple calls APNs a best-effort service 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 unless you set a ttl, so “your driver arrived” can land weeks late.
  • Payloads are small: 4 KB on APNs (5 KB for VoIP) and 4,096 bytes on FCM. So send an id and let the app fetch the rest.
  • A collapse id (apns-collapse-id, 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#

Works · One try, in the requestcorrect, simplest

Call the provider once, as in step 3.

Trade-offA slow provider slows your service, and a failed call is lost.
Scales · Workers with retries and backoffhandles the load

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; 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.

Trade-offRetries 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, and for a wait of at least a minute before retrying a quota error.
Survives · Classify the answer, expire, and clean up tokenshandles failure and growth

Not every failure deserves a retry. Mastodon’s web push worker removes a subscription on a 4xx other than 408 and 429, and raises on other failures so the job is retried.

Mobile providers need a finer rule:

Set an expiry per type: the Web Push standard requires a TTL and forbids delivery after it. When a channel reports back later, as Twilio’s status callbacks do, store the result on the same record.

Trade-offA 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.

The iOS Nudge-With-an-Id Pattern#

iOS limits background work, so ntfy’s documentation says instant notifications on iOS need a central server. 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 in use: 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.

Works · One queue for everythingcorrect, simplest

All notifications share a queue and are sent in order.

Trade-offA campaign of 10 million delays every urgent message behind it by the whole campaign, about 33 minutes at the assumed rate.
Scales · Priority laneshandles the load

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, 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.

Trade-offMore queues to watch. If everything is urgent, the lanes mean nothing.
Survives · Throttled campaigns that expirehandles failure and growth

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.

Trade-offA campaign takes minutes, and too low a throttle makes a timed offer arrive late.
A rule to remember

Decide once, store first, nudge with an id. The inbox record makes every retry safe; the device fetches the rest.

The Final Design in One Picture#

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.

TopicThe question it answers
Failure table and monitoringIf this part dies, what happens, and what do you watch?
Two regions and caller authWhere does it run, and who may publish?
Compliance and follow-upsWhich rules limit sends, and what comes after the main design?

What Each Level Should Show#

LevelExpected in the room
Mid-levelA queue and workers, channels, a provider. May need a hint about retries and duplicates.
SeniorAn 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#

✗ 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.

✓ 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.”

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?”.

Try It: A Flash Sale Alert#

Exercise · A 2 million user alert, and a login code15 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.

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.

Check Yourself#

Check yourself3 questions · answer, then open
1.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.

2.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.

3.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.

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.

Where to go from here

Frequently asked questions

How does a push notification actually reach a phone? +
Your service does not talk to the phone. It sends the notification to a push provider (Apple's APNs or Google's Firebase), and the provider delivers it over a connection the phone's operating system already keeps open. Matrix's push gateway design shows the chain: homeserver, push gateway, push provider, device.
How do you avoid sending the same notification twice? +
Give every notification an id derived from the event, and check it before sending. Retries are unavoidable, and the Matrix specification says duplicate suppression by event id is needed so that retries do not cause duplicate alerts. Updating an unread count is safe to repeat, so it needs no check.
What should happen when a push provider is down or slow? +
Do not call it inside the user's request. Store the notification, queue the delivery, and let workers retry with growing waits until the notification expires. Mastodon's push worker, for example, retries a limited number of times and skips anything older than 48 hours.
How do you handle invalid or expired device tokens? +
Delete a token only when the provider says it is unregistered: APNs answers 410 (Unregistered or ExpiredToken) and FCM answers 404 UNREGISTERED. Do not delete on a credentials error (APNs 403) or a bad payload, which would wipe good tokens. Mastodon's web push worker removes a subscription on any 4xx other than 408 and 429, and retries other failures.
How do you stop a marketing campaign from delaying a login code? +
Separate lanes. Send urgent notifications through a small, fast, high-priority lane with a short expiry, and send campaigns through a throttled bulk lane. At an assumed 5,000 a second (half of FCM's default project quota of 600,000 a minute), a 10 million user campaign takes about 33 minutes, which is far too long for a login code to wait.

References

  1. Mastodon source — NotifyService (drop, filter, save, then push)
  2. Mastodon source — web push worker (queue, retry, TTL, dead subscriptions)
  3. Matrix specification — Push Gateway API
  4. Matrix specification — Push notification endpoint (duplicate suppression, priority)
  5. Matrix specification — Push notifications module (push rules)
  6. ntfy documentation — Configuration (iOS instant notifications)
  7. ntfy documentation — Publishing (priority, scheduled delivery, updates)
  8. Apple — Sending notification requests to APNs (payload size, expiration, collapse id)
  9. Apple — Handling notification responses from APNs (status codes)
  10. Firebase — FCM error codes (UNREGISTERED, QUOTA_EXCEEDED, UNAVAILABLE)
  11. Firebase — Manage FCM registration tokens
  12. Firebase — FCM throttling and quotas
  13. Firebase — Setting the lifespan of a message
  14. Firebase blog — Time to upgrade from GCM to FCM
  15. RFC 8030 — Generic Event Delivery Using HTTP Push (TTL, Urgency)
  16. Twilio — Track the message status of outbound messages
Written by
Sukhveer Kaur
Sukhveer KaurSoftware Developer & AI Engineer

Sukhveer is a software developer specialising in AI systems and backend engineering. She has hands-on experience designing agentic AI applications, working with large language model pipelines, autonomous agent frameworks, and cloud-native services in Java and Python. At InfoWok, she bridges the gap between cutting-edge AI research and practical implementation — helping developers understand and apply emerging technologies through clear, experience-backed writing.

New AI engineering guides, the day they ship

Real Python, production depth. No digest spam.

Comments