Design a Notification System: Push, Email & SMS at Scale
How to design a multi-channel notification system for push, email, and SMS — the queue, dispatcher, dedup, retries, and a 50M-user fan-out walkthrough.
This article has an interactive companion
Don't just read it — step through it interactively, one state change at a time.
Almost every product sends notifications, and almost every one of them is harder than it looks. A single "your order shipped" email has to survive a SendGrid hiccup, respect the fact that the user muted email six months ago, and never send twice even though the shipping service retried its API call. Multiply that by push, SMS, and a breaking-news alert that must reach 50 million phones in under a minute, and you have a genuine distributed-systems problem.
The interview version rewards a specific move: separate the fast synchronous request ("accept this notification") from the slow, flaky work of actually delivering it. Everything good in this design — bursts absorbed, retries contained, duplicates dropped — falls out of that one split. What follows builds the system in the order an interviewer expects: requirements, capacity, architecture, data model, then the deep dives where the real points are.
Functional and non-functional requirements
Start by naming what the system promises. The functional requirements are the visible contract:
- Deliver a message over three channels — push (APNs/FCM), email (SES/SendGrid), and SMS (Twilio) — from one API.
- Render templates with per-user personalization ("Hi Priya, your order #4471…").
- Honor per-channel user preferences: opt-outs, quiet hours, and category toggles.
- Retry transient provider failures and expose delivery status:
sent,delivered,bounced,opened.
The non-functional requirements are the bounds that decide the architecture. Transactional notifications (password resets, OTP) want low latency — under a couple of seconds end to end — and high durability, because a dropped OTP is a support ticket. Marketing and broadcast traffic tolerates seconds of delay but arrives in enormous bursts. The system must stay available even when a third-party provider is degraded, and it must be idempotent so that an upstream retry never doubles a message. Ordering, notably, is not a hard requirement for most notifications — that relaxation buys you cheap horizontal scaling later.
Capacity estimation
Numbers turn hand-waving into a design. Assume 100 million daily active users and 10 notifications per user per day. That is 1 billion notifications/day, or about 11,500 notifications/second on average. Traffic is spiky, so plan for a peak of 5x — roughly 58,000/second.
Split that by channel, because they cost wildly different amounts. Suppose 70% push, 20% email, 10% SMS. Push is nearly free and fast; SMS at ~$0.007 per message means the 100M daily SMS messages alone cost around $700,000/day, which immediately makes "should this really be an SMS?" a product question, not just an engineering one.
Storage is modest per row but adds up. A notification record — id, user, channel, template id, status, timestamps — is roughly 300 bytes. At 1 billion/day that is ~300 GB/day of new rows, so you keep hot status data for 30–90 days (~9–27 TB) and archive the rest to cold storage. The takeaway that matters in the interview: the write path and the queue are the scaling pressure, not the API tier.
High-level architecture
The core split is a short synchronous path in front of a durable asynchronous pipeline.
Product service
│ POST /notifications
▼
[ API service ] ── validate ──▶ [ Notification DB ] (row: QUEUED)
│ publish event
▼
[ Message queue: "events" topic ] (Kafka / SQS)
│
▼
[ Dispatcher ] ── fetch prefs + device tokens ── apply template
│ enqueue per channel
├──▶ [ push queue ] ──▶ [ push workers ] ──▶ FCM / APNs
├──▶ [ email queue ] ──▶ [ email workers ] ──▶ SES / SendGrid
└──▶ [ sms queue ] ──▶ [ sms workers ] ──▶ Twilio
│
provider webhook ▼
[ status handler ] ──▶ updates Notification DBThe API service does the minimum: authenticate, validate the payload, write a QUEUED row, publish an event, and return 202 Accepted. It never talks to a provider, so its latency is stable regardless of how sick Twilio is today.
The message queue is the load-bearing piece, and interviewers will ask why. Producers (product services) and consumers (channel workers) have completely different throughput and reliability profiles — a provider can be slow or down for minutes. A durable queue like Kafka or SQS decouples them: it absorbs the 5x burst, guarantees nothing is lost if a worker crashes mid-batch, lets each channel scale its worker pool independently, and gives you retry primitives (delayed redelivery, dead-letter queues) for free.
The dispatcher is where the routing intelligence lives. It reads an event, loads the user's preferences and device tokens, drops any channel the user opted out of, renders the template, and enqueues one message per surviving channel. Doing preference evaluation here — once, upstream — instead of inside every worker is a deliberate optimization: it stops the system from rendering an email that a muted user will never receive.
Channel workers are thin. They pull from their queue, call one provider, and record the result. Keeping them per-channel means a Twilio outage backs up only the SMS queue while push and email flow normally.
Data model
Two entities carry the design. The notification row is the source of truth for status:
CREATE TABLE notifications (
id UUID PRIMARY KEY,
user_id BIGINT NOT NULL,
channel TEXT NOT NULL, -- 'push' | 'email' | 'sms'
template_id TEXT NOT NULL,
idempotency_key TEXT NOT NULL, -- hash(event_id, user_id, channel)
status TEXT NOT NULL, -- QUEUED|SENT|DELIVERED|BOUNCED|FAILED
provider_msg_id TEXT, -- returned by FCM/SES/Twilio
created_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ,
UNIQUE (idempotency_key)
);Preferences are read on the hot path for every notification, so they live as a compact per-user document, cached aggressively:
user_id: 88231
preferences:
push: { enabled: true, quiet_hours: null }
email: { enabled: false, categories: { marketing: false, security: true } }
sms: { enabled: true, quiet_hours: "22:00-07:00" }Store that document in Postgres or DynamoDB as the record of truth, and mirror it into Redis with write-through on every update. The dispatcher then does an O(1) cache read instead of a database query per notification — the difference between a lookup that scales to 58,000/second and one that melts the primary DB.
Deduplication: never notify twice
At-least-once queues will redeliver, and upstream services will retry their API calls. Without protection, the user gets the same OTP twice. The fix is a deterministic idempotency key: hash(event_id, user_id, channel). Before dispatching, the system inserts that key into a Redis set (or relies on the UNIQUE constraint above); a duplicate insert fails and the message is dropped. Because every worker derives the same key, dedup holds even if the redelivery lands on a different worker. For push, layering on FCM's collapse_key gives a second, provider-side guard.
Walkthrough: one order-shipped push notification
Trace a single event so the moving parts connect. User 88231's order ships; the shipping service (which retried once) fires two identical events.
| Step | Component | State / action | Result |
|---|---|---|---|
| 1 | API service | Validate event evt-9f2, write row QUEUED, publish to events topic | 202 Accepted |
| 2 | API service (retry) | Duplicate event evt-9f2 arrives, same key hash(evt-9f2, 88231, push) | UNIQUE violation → dropped |
| 3 | Dispatcher | Load prefs: push enabled:true, email enabled:false | Only push survives |
| 4 | Dispatcher | Fetch device token, render template, enqueue to push queue | 1 message queued |
| 5 | Push worker | Pull message, call FCM, get provider_msg_id: fcm-77a | Row → SENT |
| 6 | Status handler | FCM webhook reports delivered for fcm-77a | Row → DELIVERED |
The duplicate died at step 2 because of the idempotency key; the muted email channel was pruned at step 3 by the dispatcher, not wasted on a worker; and "delivered" was only recorded at step 6, when the provider confirmed — not when the API returned at step 1.
Deep dive: the 50-million-user fan-out
A breaking-news alert to 50M users in under 60 seconds is the burst that breaks naive designs. Generating 50 million individual database rows and API calls synchronously is hopeless. Instead:
- Precompute the audience offline or model it as a single broadcast topic, so you are not doing 50M preference lookups at send time.
- Batch device tokens in groups of up to 1,000 using FCM's batch send API — that turns 50M sends into ~50,000 provider calls.
- Partition the work queue by
user_idhash so hundreds of workers drain it in parallel with no coordination. - Lean on the fact that FCM and APNs fan out efficiently server-side; your real ceiling becomes provider rate limits and client egress bandwidth. Negotiate higher quota ahead of time and shard across multiple sender accounts if one account's throughput caps you.
Crucially, keep the broadcast on its own queue so it does not starve ordinary transactional notifications while it drains.
Deep dive: retries without retry storms
When Twilio has a partial outage, the dangerous failure mode is a retry storm — thousands of workers hammering a limping provider and knocking it fully over. Three controls prevent it:
| Control | Mechanism | What it prevents |
|---|---|---|
| Exponential backoff + jitter | Retry at 1s, 2s, 4s, 8s ±20% | Synchronized retry spikes |
| Circuit breaker per provider | Trip on error-rate threshold, cool down, divert to fallback | Hammering a sick provider |
| Capped attempts → DLQ | After N tries, route to dead-letter queue | Infinite loops; enables manual review |
Jitter is the subtle one: without it, every failed request from the same second retries at the same instant, recreating the spike. A global rate limit on retries ensures a provider that just recovered is not immediately re-buried.
Deep dive: tracking delivery status
Delivery is a state machine, not a single API return: QUEUED → SENT → DELIVERED, with BOUNCED and FAILED as terminal states. A provider call only moves the row to SENT — it confirms handoff, not receipt. Real delivery arrives asynchronously via a provider webhook: the handler validates the request signature, looks the notification up by provider_msg_id, and updates the row idempotently (so a redelivered webhook is harmless). For push specifically, FCM's send-time ack is not proof of delivery — you learn that from a client SDK callback reporting back to your own ingestion API. Aggregate all of this into a time-series store so you can alert on a bounce-rate spike, which is usually the first sign a template or sender reputation broke.
How this shows up in interviews
Interviewers use this problem to test whether you reach for a queue instinctively and whether you can reason about failure. Expect the conversation to converge on four spots: why a queue (decoupling, burst absorption, durability), how dedup works (the idempotency key and at-least-once semantics), the burst (batching and partitioned fan-out), and retries (backoff, jitter, circuit breakers). A strong answer walks one notification end to end, names the notification row as the source of truth for status, and is explicit that "accepted" and "delivered" are different states you must not conflate. If you have time, mention the SMS cost math — it signals you think about the business, not just the boxes.
Further learning
- Notification Service System Design Interview — codeKarle's whiteboard walkthrough of the dispatcher-and-queue architecture.
- Design a Notification System — a written end-to-end guide with component diagrams.
- Practice it interactively: Practice this topic on YeetCode.
- Related core designs: Design a Chat System, Design a News Feed, Design a Web Crawler, and Design an Autocomplete System.
FAQ
Why is a message queue essential in a notification system?
Because producers and consumers move at different speeds and reliability. Product services generate events in unpredictable bursts, while channel workers depend on third-party providers that can be slow or down for minutes. A durable queue like Kafka or SQS sits between them: it absorbs a 5x traffic spike without dropping anything, survives worker crashes because messages persist until acknowledged, lets each channel scale its workers independently, and provides retry machinery — delayed redelivery and dead-letter queues — out of the box. Remove the queue and a single provider outage backs pressure all the way up into your API tier.
How do you make sure a user isn't notified twice for the same event?
Give every notification a deterministic idempotency key built from hash(event_id, user_id, channel). Before dispatching, insert that key into a Redis set or rely on a unique index in the database; if the key already exists, the duplicate is silently dropped. This matters because queues deliver at-least-once and upstream services retry their API calls, so the same event legitimately arrives more than once. Since every worker derives the identical key from the same inputs, deduplication holds even when redelivery lands on a different worker, and provider features like FCM's collapse_key add a second layer for push.
How do you fan out a breaking-news alert to 50 million users in under a minute?
You avoid generating 50 million individual rows and calls. Precompute the target audience offline or represent it as one broadcast topic, then batch device tokens into groups of up to 1,000 using the provider's batch API — that collapses 50M sends into roughly 50,000 calls. Partition the work queue by a hash of user_id so hundreds of workers drain it in parallel, and put the broadcast on its own queue so it does not starve normal transactional notifications. The real ceiling ends up being provider rate limits and client bandwidth, so negotiate higher quota in advance and shard across multiple sender accounts if one caps your throughput.
How is delivery status tracked when providers deliver asynchronously?
Model each notification as a state machine: QUEUED → SENT → DELIVERED, with BOUNCED and FAILED as terminal outcomes. Calling the provider only advances the row to SENT, which confirms handoff, not receipt. Actual delivery comes back later through a provider webhook — the handler validates the signature, finds the notification by its provider_msg_id, and updates the state idempotently so a duplicate webhook does no harm. For push, the send-time acknowledgement isn't proof of delivery; you confirm that through a client SDK callback to your own ingestion API. Feeding these transitions into a time-series database lets you dashboard delivery rates and alert on bounce-rate spikes.
How do you enforce user notification preferences without slowing the pipeline?
Store each user's preferences as one compact document keyed by user id — per channel: enabled flag, quiet hours, and category toggles. Keep the record of truth in your primary database, but mirror it into Redis with write-through updates so the dispatcher reads it in O(1) instead of querying the database for every notification. Evaluate preferences once in the dispatcher, before enqueuing any channel work, rather than inside each worker — that prevents the system from rendering and queuing messages a muted user will never receive. Add global per-category kill switches so you can halt an entire notification type instantly during an incident.
Make it stick: run this one yourself
Don't just read it — step through it interactively, one state change at a time.