← System design

Communication

Design Notification System

Almost every product team eventually needs to tell a user something: your order shipped, your code is 482913, someone replied to you, a sale starts now. A notification platform gives all of them one door to knock on. Behind that door, it decides whether this user should hear about this at all, on which channel, at what hour, and how to make sure it arrives exactly once, while the providers it depends on (Apple, Google, email and SMS gateways) throttle, fail and change rules on their own schedules.
Core: per-channel queues + idempotent delivery workers1.5B notifications a dayOTP push under 5 sCampaigns to 60M users

01

The problem

01

The problem

Build an internal service that other teams call to notify users. A caller says who should be told and what kind of event happened. The platform owns everything after that: looking up the user's devices and addresses, checking preferences, rendering the right template in the right language, choosing channels, sending through external providers, retrying failures and recording what happened.

The traffic is two very different things sharing one pipe. Transactional messages such as login codes and payment receipts are triggered by one user's action and must arrive within seconds. Bulk messages such as a marketing campaign or a breaking news alert target tens of millions of people at once and can tolerate minutes of spread. The design must make sure the second kind never delays the first.

The real customers

There are two sets of users here: the people receiving notifications, who want fewer and better ones, and the internal teams sending them, who want a simple API and visibility into what was delivered. Preferences, rate caps and quiet hours exist to protect the first group from the second.

02

What the platform must do

02

What the platform must do

Functional
  • Deliver over mobile push (APNs, FCM), email, SMS and an in-app inbox, alone or in combination.
  • Callers send an event type plus variables; the platform renders templates per channel and locale.
  • Users control preferences per category and channel; legally required messages such as security alerts bypass opt-outs.
  • Respect quiet hours in the user's time zone and frequency caps per category.
  • Support scheduled sends and audience campaigns defined by a segment.
  • Track each notification through queued, sent, delivered, opened and failed, visible to the calling team.
Non-Functional
  • Transactional push and SMS reach the provider within 5 seconds at p99.
  • No duplicates: a retried request or a crashed worker must not send twice.
  • No silent loss: an accepted notification either arrives or ends in a visible failed state.
  • A 60 million user campaign completes within 20 minutes without hurting transactional latency.
  • The API stays available at 99.95% even when a provider is down.
Tensions to name early

Speed and caution pull against each other: every preference check and dedup lookup adds latency. Delivery and duplication pull against each other too: retrying more aggressively loses fewer messages but risks double sends. Say how you will resolve both, because the rest of the design follows from it.

03

Sizing the load

03

Sizing the load

Assume 120 million daily active users, an average of about 12 notifications each per day across channels, and a few large campaigns a week. Push dominates volume; SMS dominates cost.

QuantityWorkingResult
Notifications per day120M users x about 12about 1.5B
Average send rate1.5B / 86,400 sabout 17K / s
Channel mixpush 70%, in-app 20%, email 9%, SMS 1%15M SMS a day
Campaign burst60M recipients over 20 minabout 50K / s on top of baseline
Transactional shareOTP, receipts, security alertsabout 1,500 / s, all latency critical
Preference lookupsone per recipient per notificationpeak about 70K / s, served from cache
Delivery log1.5B x 4 status events x 250 Babout 1.5 TB a day
Inbox storagein-app items kept 90 days, about 400 B eachabout 11 TB
Device tokens300M users x 1.6 devicesabout 480M tokens
SMS spend15M x a few centshundreds of thousands of dollars a day
Bursts, not averages

17,000 a second on average is easy. The design is sized for a campaign tripling the rate while login codes still need to arrive in seconds. That points straight at separate queues per priority and per channel.

Cost matters

One duplicate SMS campaign can cost more than a month of servers. Deduplication and caps are not just for user experience; they protect the budget.

04

API design

04

API design

POST/v1/notificationsNotify one user about one event
// Idempotency-Key: order-77120-shipped, the caller's own stable key { "user_id": "u_8812034", "type": "order.shipped", "priority": "high", // critical | high | normal | low "data": { "order_id": "77120", "carrier": "BlueDart", "eta": "Thu" }, "channels": ["push", "in_app"], // optional; default comes from the type "expires_at": "2026-10-02T18:00:00Z" } // 202 Accepted { "notification_id": "nt_01JA2C7X", "state": "accepted" } // Same key again within 24 h returns the same id and sends nothing new
POST/v1/campaignsSend to a segment
{ "type": "promo.festive_sale", "segment": "seg_active_90d_in", "send_at": "local:10:00", // 10 a.m. in each user's time zone "rollout": { "first_percent": 1, "pause_if_error_rate_over": 0.02 } }
GET/v1/notifications/{id}Delivery status for the caller
{ "deliveries": [ { "channel": "push", "device": "ios_4f2a", "state": "delivered", "at": "2026-10-01T09:14:03Z" }, { "channel": "in_app", "state": "read" } ], "suppressed": [{ "channel": "email", "reason": "user_opted_out" }] }
GET/v1/me/inbox?cursor=...The in-app inbox for the signed-in user
// Newest first; unread count returned in a header; new items also pushed over the app's WebSocket
Callers send events, not messages

Teams send order.shipped with data, never finished text. Copy, translation, channel choice and branding live in templates owned by the platform, so a product manager can change wording without a deploy in five services.

05

Architecture overview

05

Architecture overview

From a caller event to a providerDiagram
flowchart LR
	C["Calling services"] --> API["Notification API"]
	API --> ID[("Idempotency store")]
	API --> IK[["Intake topic"]]
	CM["Campaign planner"] --> IK
	IK --> PR["Router: prefs, caps, quiet hours, render"]
	PR --> PF[("Preferences and devices cache")]
	PR --> QP[["Push queues by priority"]]
	PR --> QE[["Email queue"]]
	PR --> QS[["SMS queue"]]
	PR --> INB[("In-app inbox store")]
	QP --> WP["Push workers"] --> APN["APNs and FCM"]
	QE --> WE["Email workers"] --> ESP["Email provider"]
	QS --> WS["SMS workers"] --> SMSP["SMS gateways"]

The API does as little as possible on the request path: authenticate the caller, check the idempotency key, validate the payload against the event type's schema, write to an intake topic and return 202. Everything slow happens after that, so a provider outage never makes the API slow.

The router consumes intake events and turns each into zero or more channel deliveries. It loads the user's preferences and devices from a cache, applies opt-outs, quiet hours and frequency caps, renders the templates, and writes each delivery to the queue for that channel and priority. In-app items go straight to the inbox store, and an online user gets a live nudge over their WebSocket.

Each channel has its own worker pool that talks to external providers. Separate pools mean an email provider slowdown cannot back up push, and each pool can be rate limited to match its provider's contract. Workers record every outcome to a delivery log, which feeds the status API, analytics and the token cleanup job.

Priority lanes

Critical (OTP, security), high (transactional), normal and low (marketing) each get their own queues and workers. Low priority never borrows capacity from critical, though the reverse is allowed.

Router

The policy brain. Stateless apart from caches, horizontally scaled, and the only place preferences and caps are enforced, so no channel can skip them.

Campaign planner

Expands a segment into recipients in pages, spreads them by time zone and throttles the rate it feeds into intake, so a big campaign arrives as a steady stream.

Delivery log

An append-only stream of state changes, stored in a columnar store for analytics and a key-value store for the status of recent notifications.

06

Deep dive: fan-out and exactly-once in practice

06

Deep dive: fan-out and exactly-once in practice

The questions behind the design

How do you send 60 million messages without starving login codes? How do you guarantee nothing is lost and nothing is sent twice when workers and providers fail in the middle? And how do you keep the in-app inbox fast for people who never open it?

Lifecycle of one delivery inside a channel workerDiagram
flowchart TD
	Q["Take delivery from queue"] --> EXP{"Expired or user now opted out?"}
	EXP -->|yes| DROP["Mark suppressed, ack"]
	EXP -->|no| CL{"Claim delivery id in dedup store"}
	CL -->|already sent| ACK["Ack without sending"]
	CL -->|claimed| SEND["Call provider with its idempotency key"]
	SEND --> RES{"Provider result"}
	RES -->|success| OK["Log sent, ack"]
	RES -->|bad token| TOK["Disable token, log failed, ack"]
	RES -->|throttled or 5xx| RETRY["Release claim, requeue with backoff"]
	RETRY --> DLQ{"Attempts above limit?"}
	DLQ -->|yes| DEAD["Dead letter queue, alert"]

Campaign fan-out without collateral damage

A campaign is not one big job; it is millions of small ones released at a controlled rate. The planner reads the segment in pages of, say, 10,000 users and emits intake events at a token-bucket rate chosen for the campaign. A 60 million push campaign at 50,000 a second takes 20 minutes, which is fine for a sale and avoids hitting provider limits.

Release it in steps. Send to 1 percent first and watch the error and unsubscribe rates for a few minutes. If something is wrong, such as a broken deep link or a template rendering "Hi {name}", the planner pauses automatically and the damage is limited to a small slice. Time zone aware campaigns are split into 24 or more buckets, each released when the local clock hits the target hour.

Because campaigns run in the low priority lane, the router and workers for critical traffic are unaffected. If the low lane backs up, messages wait; if they wait past their expiry, they are dropped as stale rather than arriving at midnight.

At-least-once delivery, made effectively once

Every hop in this pipeline can fail after doing its work but before confirming it. The only safe policy is at-least-once: never acknowledge a message until its outcome is recorded, and accept that retries will happen. Duplicates are then removed with idempotency at three points.

At the API, the caller's key maps to the original notification ID for 24 hours, so a caller retrying a timeout creates nothing new. In the router, each delivery gets a deterministic ID such as a hash of notification ID, channel and device, so reprocessing an intake event yields the same deliveries. At the worker, a claim in a fast key-value store with a short lease marks the delivery as in progress before the provider call and as sent after it. A second worker that picks up the same delivery sees the claim and skips it.

There is still one gap: a worker can call the provider, the provider can send, and the worker can crash before writing "sent". When its lease expires, another worker retries. For email and some SMS gateways you can pass the delivery ID as the provider's own idempotency key and the duplicate is dropped there. For push, the platforms accept a collapse identifier, so a repeated push replaces the first on the device instead of showing twice. That brings the visible duplicate rate close to zero.

Retries that help instead of hurt

Classify provider responses before retrying. A throttling response or a 5xx means try again later, with exponential backoff and jitter. An invalid or unregistered token means the device is gone: disable the token and stop. A content rejection means the message will never succeed: fail it and alert the owning team. Retrying permanent errors wastes provider quota and can get your sender account flagged.

Each channel has a retry budget tied to the message's usefulness. A login code older than five minutes is worthless, so it expires; a receipt email can retry for a day.

The in-app inbox

The inbox is a per-user list stored in a wide-column table keyed by user ID and sorted by time, which makes "latest 20" a single partition read. Writes happen at send time, so reads never compute anything. The unread count lives as a small counter next to the list. When the user is online, the router also pushes a lightweight event over their existing app connection so the bell updates instantly; when offline, nothing extra is needed because the next open reads the list.

A strong interview answer

"The API validates, dedups on the caller's key and enqueues. A router applies preferences, caps and quiet hours, renders templates and splits work into per-channel, per-priority queues. Workers deliver at least once with a claim in a dedup store, pass the delivery ID to providers that support idempotency, and classify errors before retrying. Campaigns are released by a throttled planner in the lowest lane with a 1 percent canary."

07

Decisions and what they cost

07

Decisions and what they cost

Chosen
Separate queues per channel and priority

Isolation by construction: an email outage or a campaign cannot delay a login code. You operate more queues and more worker pools, and need dashboards per lane.

Protects critical traffic
Rejected
One shared queue with a priority field

Simpler, but a backlog of millions of low priority messages sits in front of the urgent ones, and most brokers cannot reorder by priority at that depth.

Head-of-line blocking
Chosen
At-least-once plus layered idempotency

Nothing accepted is lost, and duplicates are caught at the API, the router and the worker. The cost is a dedup store on the hot path and careful ID design.

Reliable and honest
Rejected
At-most-once, fire and forget

Fast and cheap, never a duplicate, but a crash silently drops receipts and login codes. Users notice missing codes far more than an occasional repeat.

Silent loss
Chosen
Check preferences centrally in the router

One enforcement point, so no channel or caller can forget an opt-out. The router needs a fast, well-cached preference store.

Compliance in one place
Rejected
Each caller checks preferences itself

Spreads the logic across dozens of services, each with its own bugs. One missed check is a regulatory complaint.

Unenforceable
Chosen
Inbox written at send time

Reading the inbox is a single partition scan. Writes cost one row per recipient, which for a campaign means tens of millions of rows quickly.

Fast reads
Alternative
Broadcast items merged at read time

For an announcement to everyone, store one item and merge it into each inbox when read. Saves writes for huge audiences, at the price of a more complex read and harder per-user read state.

For global broadcasts

08

Failure modes

08

Failure modes

📵
A push provider is down or throttling

FCM returns errors for twenty minutes. Push workers retry hard, pile up connections and start timing out on APNs traffic too.

Use a circuit breaker per provider, back off with jitter, keep each provider's workers isolated, and for critical messages fall back to SMS after a short deadline.
♊
A worker crashes after sending

The provider accepted the message, but the worker died before marking it sent. Another worker picks it up and the user gets the same alert twice.

Pass the delivery ID as the provider's idempotency key where supported, use collapse IDs for push, and keep the claim lease short so a retry is fast but rare.
🧨
A campaign goes out with a broken template

A missing variable renders as raw placeholder text, headed to 60 million phones.

Validate templates against sample data before launch, start with a 1 percent canary, pause automatically on error or unsubscribe spikes, and give operators a kill switch per campaign.
🔁
A calling service loops and spams one user

A bug makes a service send "your item is back in stock" every second to the same user.

Enforce frequency caps per user and category in the router, rate limit each caller, and alert when one user receives far more than normal.
🪦
Dead device tokens accumulate

People uninstall the app, but their tokens remain. A growing share of every send is wasted, and providers may penalise senders with high invalid rates.

Disable tokens on the first unregistered response, refresh them on every app open, and expire tokens with no successful delivery for 60 days.
🌙
Messages arrive at 3 a.m.

The low priority lane backs up for hours and marketing pushes are delivered in the middle of the night.

Re-check quiet hours and expiry at delivery time, not only at routing time, and drop or reschedule anything that has gone stale.

09

Mistakes to avoid

⚠

Mistakes to avoid

🚫
Calling providers synchronously from the API request

The caller's request now waits on APNs or an SMS gateway. When a provider is slow, the calling service's threads pile up and its own users suffer.

✓ Better: accept, enqueue and return 202; deliver from workers.
🚫
Retrying every failure the same way

Retrying invalid tokens or rejected content burns quota, slows queues and can get the sender account flagged as abusive.

✓ Better: classify errors into retryable, permanent and token problems, and act on each differently.
🚫
Letting callers send finished text

Copy spreads across services, translations drift, and there is no single place to fix a typo or check a template.

✓ Better: callers send typed events with data; templates live in the platform.
🚫
Launching a campaign to everyone at once

A single mistake reaches every recipient before anyone can react, and the burst trips provider limits.

✓ Better: throttled release with a small canary and automatic pause rules.

10

How to handle the interview

09

How to handle the interview

1

Separate transactional and bulk traffic in the first five minutes. It explains the queue layout and shows you understand why a sale must never delay a login code.

2

Be explicit about delivery semantics. Say "at least once, deduplicated" and then walk through where each idempotency key lives.

3

Describe the crash-after-send gap. Admitting it exists, and naming collapse IDs and provider idempotency as the fix, is a strong signal.

4

Make preferences central. One enforcement point in the router, with quiet hours and caps checked again at delivery time.

5

Treat external providers as unreliable. Circuit breakers, error classification and SMS fallback for critical messages.

6

Talk about cost. SMS is expensive, so dedup and caps are financial controls as well as user experience.

7

Show campaign safety. Canary, throttle, automatic pause. Interviewers often ask what happens when a campaign is wrong.

11

Growing the design in stages

11

Growing the design in stages

Stage 1: one product, low volume
Send from the application

The app calls an email API and a push SDK directly inside request handlers. Works until a provider has a bad day and the app slows down with it.

Stage 2: a few teams
A queue and background workers

Sends move to a job queue with retries. Templates and device tokens move into one shared service.

Stage 3: marketing joins in
Priorities, preferences and caps

Campaigns arrive, so lanes split by priority and channel. A central preference and policy router enforces opt-outs and quiet hours.

Stage 4: a billion a day
Idempotent delivery at scale

Dedup claims, provider idempotency, error classification and a delivery log. The fire-and-forget workers are retired.

Stage 5: smarter sending
Choosing channel and timing per user

Models predict the best channel and send time for each user, digests bundle low value alerts, and global regions send through local providers.

Watch and read

Try next

Free to read · better with landed

Whiteboard this with landed

landed runs it as a live system design round on the whiteboard and grades your trade-offs.