Communication
Design Notification System
01
The problem
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.
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
What the platform must do
- 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.
- 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.
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
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.
| Quantity | Working | Result |
|---|---|---|
| Notifications per day | 120M users x about 12 | about 1.5B |
| Average send rate | 1.5B / 86,400 s | about 17K / s |
| Channel mix | push 70%, in-app 20%, email 9%, SMS 1% | 15M SMS a day |
| Campaign burst | 60M recipients over 20 min | about 50K / s on top of baseline |
| Transactional share | OTP, receipts, security alerts | about 1,500 / s, all latency critical |
| Preference lookups | one per recipient per notification | peak about 70K / s, served from cache |
| Delivery log | 1.5B x 4 status events x 250 B | about 1.5 TB a day |
| Inbox storage | in-app items kept 90 days, about 400 B each | about 11 TB |
| Device tokens | 300M users x 1.6 devices | about 480M tokens |
| SMS spend | 15M x a few cents | hundreds of thousands of dollars a day |
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.
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
API design
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
Architecture overview
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.
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.
The policy brain. Stateless apart from caches, horizontally scaled, and the only place preferences and caps are enforced, so no channel can skip them.
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.
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
Deep dive: fan-out and exactly-once in practice
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?
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.
"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
Decisions and what they cost
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 trafficSimpler, 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 blockingNothing 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 honestFast 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 lossOne enforcement point, so no channel or caller can forget an opt-out. The router needs a fast, well-cached preference store.
Compliance in one placeSpreads the logic across dozens of services, each with its own bugs. One missed check is a regulatory complaint.
UnenforceableReading 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 readsFor 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 broadcasts08
Failure modes
Failure modes
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.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 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 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.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.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
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.
Retrying invalid tokens or rejected content burns quota, slows queues and can get the sender account flagged as abusive.
Copy spreads across services, translations drift, and there is no single place to fix a typo or check a template.
A single mistake reaches every recipient before anyone can react, and the burst trips provider limits.
10
How to handle the interview
How to handle the interview
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.
Be explicit about delivery semantics. Say "at least once, deduplicated" and then walk through where each idempotency key lives.
Describe the crash-after-send gap. Admitting it exists, and naming collapse IDs and provider idempotency as the fix, is a strong signal.
Make preferences central. One enforcement point in the router, with quiet hours and caps checked again at delivery time.
Treat external providers as unreliable. Circuit breakers, error classification and SMS fallback for critical messages.
Talk about cost. SMS is expensive, so dedup and caps are financial controls as well as user experience.
Show campaign safety. Canary, throttle, automatic pause. Interviewers often ask what happens when a campaign is wrong.
11
Growing the design in stages
Growing the design in stages
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.
Sends move to a job queue with retries. Templates and device tokens move into one shared service.
Campaigns arrive, so lanes split by priority and channel. A central preference and policy router enforces opt-outs and quiet hours.
Dedup claims, provider idempotency, error classification and a delivery log. The fire-and-forget workers are retired.
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.
