Design a notification system
Push, SMS and email to a billion devices with priorities, dedup, rate limits and opt-outs.
- Message queues and pub/sub — Decoupling producers from consumers with a durable log in between.
- Push notifications — Reaching a device that is asleep, through APNs and FCM.
- Idempotency and exactly-once — Making a repeated request harmless, which is how 'exactly once' is really built.
- Rate limiting — Token buckets, sliding windows, and enforcing them across many servers.
- Retries, timeouts and backoff — Retrying without creating the storm that takes the service down.
01The design, in the six-step framework
1. Requirements
Functional
- Any internal product can request a notification to a user over push, SMS or email.
- Users control channels and categories (marketing off, security on); the system enforces it.
- Priorities: a login alert must arrive within seconds; a weekly digest can wait hours.
- Deduplicate: the same event from a retrying caller produces one notification.
- Out of scope: composing content, in-app inbox UI, analytics dashboards.
Non-functional
- One billion registered devices; 10 billion notifications a day.
- Critical priority delivered to the provider in under 5 s, p99; bulk within an hour.
- At-least-once delivery to the provider, with dedup so users see each notification once.
- Per-user rate limit (no more than 10 marketing messages a day) and per-provider limits.
- 99.99 percent availability of the intake API; the system must absorb a 50x burst.
10 × 109 ÷ 86,400 ≈ 115,000 notifications per second on average.
A product launch or a breaking-news push sends 100 million in a minute: 1.7 million per second, 15 times the average, so the queue is the shock absorber, not the servers.
A record of 300 bytes per notification is 3 TB a day; keep 30 days hot for dedup and status, about 90 TB.
Provider limits: APNs and FCM accept tens of thousands of requests per second per connection pool, so the sender tier is about 100 workers per provider at the burst rate.
2. Core entities
- Notification request: request_id (caller supplied), user_id, template_id, payload, priority, category, created_at.
- Device token: user_id, device_id, provider (APNs, FCM), token, last_active.
- Preference: user_id, channel, category, allowed, quiet hours.
- Delivery attempt: notification_id, channel, provider response, attempt count, state.
- Template: template_id, per-locale text, required fields.
3. API
POST /notificationswith request_id, user_id, template_id, data, priority, category; returns 202 and a notification_id. Called by internal services.GET /notifications/{id}: current state per channel, for the caller's dashboards.PUT /users/{id}/preferences: channel and category switches, quiet hours. Called by the settings UI.POST /devices: register or refresh a push token. Called by the app on launch.POST /providers/{name}/callbacks: delivery and bounce reports from SMS and email vendors.
4. High-level design
A login alert for one user:
- The auth service calls the intake API with request_id = the login event id and priority critical.
- The intake API validates the template, writes nothing synchronous except the enqueue to the critical queue, and returns 202 in a few milliseconds.
- A router consumer reads the event, checks the dedup key
request_idin Redis (set with a 24 h TTL), loads the user's preferences and device tokens, and applies rate limits; security alerts bypass the marketing cap. - It writes the notification row with state queued and hands one job per channel to the push workers.
- A push worker renders the template in the user's locale and calls FCM or APNs; on success it records delivered-to-provider. A failed token is marked dead so it is never tried again.
- Provider callbacks (bounces, unsubscribes) arrive later and update the log and preferences.
5. Deep dives
How do we guarantee a user is never notified twice when callers retry?
Callers time out and resend; queues redeliver after a consumer crash. The simple answer, trust the caller, gives duplicates in exactly the moments that matter. The router uses the caller's request_id as an idempotency key: SET request_id NX EX 86400 in Redis succeeds for the first copy only. That covers caller retries. For queue redelivery, the worker marks the notification row sent-to-provider before acknowledging the queue message; if it crashes between the provider call and the mark, the retry may send twice. That window is milliseconds and is the accepted at-least-once cost; the alternative, a distributed transaction with the provider, does not exist.
How does a critical alert get through during a 100-million bulk push?
One queue means the login alert waits behind 100 million digests. Three queues by priority, each with its own consumer pool, fix the head-of-line problem, but a shared sender tier can still be saturated. The design reserves capacity: critical workers are a separate pool that bulk jobs cannot use, and bulk workers obey a token bucket per provider so they never consume the provider's whole quota. Backpressure works in the other direction too: when the bulk queue depth passes a threshold, the intake API answers bulk callers with 429 and a retry-after instead of accepting what it cannot send today.
What happens when a provider is down or rate-limiting us?
| Option | Behaviour | Trade-off |
|---|---|---|
| Retry immediately | Hammer the provider | Turns a slow provider into a dead one |
| Exponential backoff with jitter | Retry at 1, 2, 4, 8 s plus noise, cap at 1 h | Critical alerts may be late; needs a dead-letter queue |
| Circuit breaker plus channel fallback | Stop calling a failing provider for 30 s; send critical alerts by SMS instead | SMS costs money; must be limited to critical |
Use backoff with jitter for everything and a circuit breaker with SMS fallback for critical only; the cost is bounded because critical is a small fraction of volume.
Where do preferences live so that a billion checks a day are cheap?
Reading a preferences table for every notification is 115,000 reads a second against a database that is written a few times a day per user. Cache the whole preference record per user in Redis with a one-hour TTL and invalidate on write; the hit rate is above 99 percent because active users receive many notifications. Quiet hours are evaluated in the user's time zone at routing time, and a deferred notification is re-enqueued with a delay rather than held in memory.
How do we keep a billion device tokens correct?
Tokens rotate when an app is reinstalled or the OS refreshes them, and a stale token wastes a provider call and sometimes a whole push quota. The app re-registers its token on every launch and the device table keeps last_active; the router prefers the two most recently active devices per user instead of spraying all of them, which also answers the multi-device question (a user with a phone, a tablet and a watch sees the alert once on the devices they actually use). Provider responses that say unregistered mark the token dead immediately, and a nightly job purges tokens unused for 90 days. The table is keyed by user_id so the router's lookup is one read, and it is sharded by user_id because the only access pattern is per user.
How does a caller find out what happened to its notification?
Every stage writes a state transition to the notification log: queued, routed, suppressed (with the preference or limit that suppressed it), sent-to-provider, delivered, bounced. The log is written by user_id so the support team can answer "why did this person not get the alert" with one query, and a stream of the same transitions feeds counters per template and per provider so a sudden rise in bounces for one vendor pages someone within a minute. This is the part most designs leave out and the part that matters most in production.
6. What a strong answer sounds like
- Lead with the shape: an intake API that only enqueues, a router that decides, workers per channel that send; the queue absorbs bursts the servers cannot.
- Quote 115,000 per second average against 1.7 million per second in a burst, and say which part of the design handles the gap.
- Be precise about delivery semantics: at-least-once to the provider, dedup by request_id, a small duplicate window you can name.
- Expect follow-ups on priority isolation, on provider failure, and on observability: which stage dropped the notification and why.
- A common mistake is to render the template and call the provider inside the intake request, which couples caller latency to the slowest vendor.
02Where this question was reported
- Exponent · 'routing time-sensitive alerts across push, email, and SMS'
- IGotAnOffer · Mid-level SWE: interviewers 'will likely ask you to design a notification system, newsfeed, job queue, or caching layer'
- IGotAnOffer · 'notification system for an app' (Google TPM)
- System Design Handbook · 'Notification or Messaging System'
- System Design Handbook · Only full prompt quoted: 'Design a notification system' as a typical vague L5 opener
- System Design Handbook · 'notification system used by multiple products', called a common prompt
- DesignGurus · 'globally distributed notifications service for Google products' with L5 expectations (sub-100ms, 99.99%)
- scaleengineer.com · 'real-time notifications for a social media platform'
- scaleengineer.com · Round 2: 'real-time notifications for a large user base'
- PracHub · 'global real-time notification system', Senior+, Jan 2026
- PracHub · 'at-least-once notification delivery'; PM: personalized push for a news app
- System Design Handbook · L5 end-to-end example: globally distributed notifications service for Gmail/YouTube/Maps/Ads; probes region failure, consistency of read state, P99, 10x-100x.