Design a Notification System
Design a service that sends push, SMS and email at scale: per-channel queues, retries, duplicates, preferences, rate limits, provider outages, priority.
On this page
Your bank texts you a login code, a shop emails a receipt, and your phone buzzes when a parcel ships. Behind each of these, some product service said "tell user 42 about this", and a shared notification service decided how, when and through which company's network the message actually left.
Interviewers ask this question because the hard parts are not the happy path. They are what happens when a provider times out and you do not know whether the text went out, when a marketing campaign for 50 million users sits in front of a login code, and when a retry sends someone the same alert three times. The steps follow A Framework for Any Design.
Clarify the problem
Design a notification system.
Is this a shared service that other teams call, sending push, SMS and email? And do we send through third-party providers rather than run our own SMS gateway or mail servers?
Yes to both. Other services call you. Providers do the last mile.
What kinds of messages: one-off alerts like login codes and order updates, or also marketing campaigns to millions of users at once?
Both. Campaigns can target 50 million users.
Volumes per day? And how fast must a login code arrive?
About 500 million push, 100 million emails and 5 million texts a day, across 100 million users. Login codes within seconds, even during a campaign.
Then I'll treat priority as a first-class requirement, respect user preferences, and aim for at-least-once sending with deduplication, because a lost login code is worse than an occasional duplicate. I'll leave out the in-app inbox and campaign targeting tools.
- Functional: other services send a notification to a user by template, on one or more channels (push, SMS, email); campaigns to large segments; users choose which categories they get on which channel; callers can ask for the status of a notification.
- Non-functional: high-priority messages (login codes, security alerts) reach the provider within 5 seconds, even during a campaign; an accepted notification is never silently dropped; duplicates are rare; accepting requests is 99.99% available, while sending may lag when providers struggle.
- Out of scope: the in-app notification inbox, campaign audience tools, A/B tests of message text.
Estimate the scale
- Average rates: 500 million push ÷ 86,400 s ≈ 5,800 a second; 100 million emails ≈ 1,160 a second; 5 million texts ≈ 58 a second. About 7,000 a second in total.
- Campaign peak: the average hides the real peak. A campaign to 50 million users that should finish within 30 minutes needs 50,000,000 ÷ 1,800 s ≈ 28,000 sends a second, four times the daily average, on one channel.
- Preferences and devices: 100 million users × 200 bytes of preferences = 20 GB; at 2 devices each and 200 bytes per device token, 40 GB. Both fit in a cache, which matters because every send reads them.
- Provider limits: assume our SMS provider account allows 500 texts a second. That is a hard ceiling we must stay under, not a target.
If all push notifications share one first-in-first-out queue, how long does a login-code push wait behind a 50-million-user campaign that was queued just before it?
- 50 million campaign pushes already in the queue
- Senders drain the queue at 28,000 pushes a second
Show the working
- 50,000,000 ÷ 28,000 ≈ 1,800 seconds
- 1,800 s = 30 minutes
Estimate 30 minutes (anything from 10 minutes to 90 minutes is a good answer)
The code expires long before it is sent. This one number justifies separate queues by priority.
How much storage does a 90-day log of every notification take, before replication?
- 605 million sends a day across all channels
- About 500 bytes of log per send (ids, template, parameters, status)
- Keep 90 days
Show the working
- 605 × 106 × 500 B ≈ 300 GB a day
- 300 GB × 90 ≈ 27 TB
Estimate 27 TB (anything from 8 TB to 80 TB is a good answer)
Write the endpoint a calling service uses to send one notification. What does it return, and how does a caller retry safely after a timeout?
Compare with a model answer
A POST that names the user, a template, its parameters, a priority and an idempotency key, and returns 202 Accepted with a notification id. Sending happens later; the caller polls a status endpoint if it cares. A retry with the same key returns the same id instead of sending twice.
Define the API
POST /v1/notifications
Idempotency-Key: order-881-shipped
body: { "user_id": 42, "template": "order_shipped", "params": { "order": "881" },
"category": "order_updates", "priority": "high" | "normal" | "bulk",
"channels": ["push", "email"]?, "expires_in": "24h"? }
202 Accepted { "notification_id": "n_501" }
400 Bad Request unknown template or missing parameter
404 Not Found unknown user
409 Conflict same Idempotency-Key with a different body
429 Too Many Requests this caller is over its quota
GET /v1/notifications/n_501
200 { "push": "sent", "email": "retrying", "sms": "skipped: user opted out" }
PUT /v1/users/42/preferences { "order_updates": ["push", "email"], "marketing": [] }
POST /v1/campaigns { "segment": "...", "template": "...", "send_at": "..." } 202
POST /webhooks/{provider} delivery receipts and bounces from providersThe decision worth arguing is 202, not 200. Sending depends on providers that can be slow or down for minutes, and the caller (an order service, a login flow) should never wait on them. Accept the request, store it, return an id. The cost is that the caller cannot learn about failures in the response; it gets a status endpoint instead. The Idempotency-Key makes the caller's own retries safe, as in Idempotency and Retries.
What records do you need, which are read on every send, and which kind of store suits each?
Compare with a model answer
A notification log (one row per request, plus a status per channel) that is written heavily and read by id; and small per-user records (preferences, device tokens, contact details) that are read on every send and should sit in a cache. Different access patterns, so they can live in different stores.
Model the data
notifications -- about 500 bytes, kept 90 days
notification_id CHAR(26) PRIMARY KEY
idempotency_key VARCHAR(100) UNIQUE (per caller), expires after 24 h
user_id BIGINT
template, params, category, priority, created_at, expires_at
deliveries -- one row per (notification, channel, device)
notification_id, channel, target -- target: device token, phone or email
status -- queued | sent | delivered | failed | skipped
attempts, provider, provider_msg_id, last_error, updated_at
preferences PRIMARY KEY (user_id) -- category -> allowed channels, quiet hours, time zone
devices PRIMARY KEY (user_id, device_token) -- platform, last_seenThe log is write-heavy (thousands of inserts and status updates a second), read by id, and expires after 90 days: a key-value or wide-column store with a TTL suits it (such as DynamoDB or Cassandra). Preferences and devices are small and change rarely, but every send reads them. Keep them in a relational database, which suits the occasional update and the support tools that query it, and put a read-through cache in front. The idempotency key needs an atomic "insert if absent", which both kinds of store offer for a single key.
Sketch the path from a calling service to a phone, an inbox and a handset. Where do you check preferences, and where do queues go?
Compare with a model answer
A thin API that validates, deduplicates, logs and returns 202; a router that applies preferences, rate limits and templates; then a separate queue and sender pool per channel, each talking to its provider. Channels fail and scale independently.
High-level design
Split the work at the two places where speed changes. The API must answer callers in milliseconds, so it only checks, records and enqueues. The providers are slow and unreliable, so each channel gets its own queue and sender pool, and a slow email provider never holds up a push.
request and replyasynchronous message
Follow one "order shipped" notification from the order service to a phone and an inbox, including a retry from the caller and a timeout at the email provider.
1The order service calls
POST /v1/notificationsfor user 42 with keyorder-881-shipped, priority normal.- rows for key
- 1
2The API inserts the key and the notification in one conditional write. The key is new, so the insert succeeds:
n_501, queued.- rows for key
- 1
3It puts
n_501on the intake queue and answers202. The caller waited a few milliseconds, not for any provider.- rows for key
- 1
4The 202 was lost on the network, so the order service retries with the same key. The insert finds the key, and the API returns
n_501again. Nothing new is queued.- rows for key
- 1
5A router picks up
n_501and reads user 42's settings from the cache: order updates by push and email, not SMS. Two phones are registered.- jobs
- 3
6It renders the template for each channel and enqueues two push jobs (one per phone) and one email job. SMS is recorded as skipped, so the status endpoint can explain it.
- jobs
- 3
- sent
- 2
7Push senders hand both messages to the platform push services, which accept them. Status: sent. Delivery to the phone is now up to the platform.
- jobs
- 3
- sent
- 2
- attempts
- 1
8The email sender calls the provider and times out after 5 seconds. It cannot tell whether the email went out.
- jobs
- 3
- sent
- 3
- attempts
- 2
9It schedules a retry with backoff and sends the provider the same message id it used before. A provider that honours that id drops the copy if the first attempt had landed.
- jobs
- 3
- sent
- 3
- attempts
- 2
10The order service asks for the status. The API reads the log: push sent to 2 devices, email sent on attempt 2, SMS skipped by preference.
Deep dives
The pipeline: queues per channel and per priority
Channels differ in speed, cost and failure. Push is fast and cheap; email providers can be slow; SMS is capped at 500 a second. If they share a queue, the slowest channel sets everyone's pace. The 30-minute estimate shows that priority needs the same treatment.
| Option | Good at | Bad at | Pick it when |
|---|---|---|---|
| One queue for everything | One thing to run and watch. | An email outage or a campaign blocks every login code behind it. | A prototype with one channel. |
| One queue per channel | Channels scale and fail independently. | Within a channel, a 50-million campaign still delays urgent messages by 30 minutes. | No bulk traffic at all. |
| Per channel and priority Usual default | High-priority work has its own queue and reserved senders, so it never waits behind bulk. | More queues to run; bulk must be throttled by hand so it does not starve. | Any mix of urgent and bulk messages. My choice. |
request and replyasynchronous message
The three API priorities map to two queues: high and normal share the fast one, bulk gets its own. Give the fast queue a guaranteed share of senders and of each provider's rate limit, say 20%, and let bulk use the rest. Run campaigns at a set pace below the provider ceiling: 28,000 pushes a second for 30 minutes is fine. Retries go to a separate delay queue, so a failing message never blocks the head of the main queue.
Retries and duplicates
Every queue here delivers at least once (see Message Queues and Streams), so a sender can crash after the provider accepted a message but before it marked the job done. The job runs again, and the user gets two texts. You cannot make this exactly once: the provider call and your own status write are two systems with no shared transaction. You can make duplicates rare:
- Before sending, check the
deliveriesrow; skip it if it already sayssent. This closes most of the window, not all of it. - Send a stable id with each request (
n_501:email). Some providers drop repeats of an id they have seen; push platforms can collapse notifications that share a collapse key on the device. - Retry only errors that can succeed later: timeouts, 5xx and 429. An invalid device token or a hard email bounce will never succeed; remove the token or suppress the address instead.
- Back off exponentially with random jitter (1 s, 2 s, 4 s and so on, capped at 10 minutes) until the notification's deadline, then dead-letter it.
The same gap exists at intake. The API writes the log, then enqueues. If it crashes in between, the notification is recorded but never queued, and the caller's retry finds the key and stops there. So a sweeper re-enqueues anything still marked queued after a minute, and routers skip ids they have already routed.
The deadline is a product decision per category, and it is where judgement shows. A login code is useless after 5 minutes, so drop it then. A shipping update is still useful a day later. For login codes a duplicate is far better than a missing code, so retry aggressively; for marketing, a duplicate annoys people and costs money, so retry less and accept the odd loss.
Preferences and rate limits
Check preferences in the router, as late as possible. A campaign that starts at 09:00 reaches some users at 09:29; one who opted out at 09:15 must be skipped. That works because the campaign job feeds work in at the send pace, so little waits in the bulk queue after routing. Some categories cannot be turned off (security alerts), and the preference model must say so. Quiet hours need the user's time zone: a normal message that falls in the night is parked in a scheduled store and released in the morning; a high-priority one goes through.
Three kinds of limit protect different things:
- Per user, so people are not spammed: for example at most 3 marketing pushes a day, counted in the cache with a key like
42:marketing:2026-11-14. - Per caller, so a buggy service in a loop cannot flood users or the queues: a quota per API key, answered with 429.
- Per provider, so we stay under the 500 texts a second our account allows: a token bucket shared by all SMS senders. See Rate Limiting.
When a provider fails
Providers fail in three ways: errors, slowness and silent loss. Errors are easy to see. Slowness is worse, because senders sit waiting and the queue grows. Put a short timeout on every call and a circuit breaker per provider: after, say, half the calls in 30 seconds fail, stop calling it for a while and send elsewhere or hold the work in the queue.
| Option | Good at | Bad at | Pick it when |
|---|---|---|---|
| Retry the same provider | Simple; no second contract or integration. | A long outage means a long backlog. | Push, where each platform is the only route to its phones. |
| Fail over to a second provider Usual default | Keeps SMS and email flowing through an outage. | A timed-out request may have been sent by the first provider, so failover can duplicate. Two integrations to maintain. | SMS and email, especially login codes. My choice. |
| Split traffic across providers all the time | Both integrations stay tested; an outage halves capacity instead of stopping it. | Twice the integration and monitoring work every day. | Very high SMS or email volume, where one provider's limit is not enough. |
Silent loss is the hardest: the provider says "accepted" and nothing arrives. Watch delivery receipts from the provider webhooks, and alert when the delivered rate for one provider drops well below its usual level. For login codes, users notice before you do; give them a "resend code" button that goes through the high-priority queue.
Bottlenecks and failures
At 10x, campaigns need about 280,000 sends a second and the log takes about 270 TB over 90 days. Senders and routers are stateless and scale out. The first real wall is the providers: their rate limits do not grow because yours did. Spread load across several provider accounts or providers, and stretch campaigns over longer windows. The preference cache comes next, read on every send: replicate it and keep a few seconds of copies in each router's memory.
| When this dies | What happens | What you do |
|---|---|---|
| The API | Callers get errors or timeouts. | Callers retry with the same idempotency key; run several API instances behind a load balancer. |
| A router or sender | Its in-flight jobs are not acknowledged. | The queue hands them to another worker after a timeout; the sent check limits duplicates. |
| The preference cache | Routers cannot check preferences. | Fall back to the database at a limited rate; pause bulk sends rather than risk ignoring opt-outs. |
| An SMS or email provider | Sends fail or hang. | The circuit breaker opens; fail over to the second provider; high priority first. |
| A platform push service | Push jobs back up in the queue. | Retry with backoff until each job's deadline; no failover exists for push. |
What a strong answer sounds like
| Level | What the answer does |
|---|---|
| Junior | An API that calls the providers directly, with a database of preferences. Adds a queue when asked about slow providers. |
| Mid | Returns 202, uses a queue per channel, checks preferences, retries with backoff, and mentions idempotency keys. |
| Senior | Finds the campaign-versus-login-code conflict from the numbers and solves it with priority queues and reserved capacity; is precise that duplicates can be made rare but not impossible; sets deadlines per category; plans for provider slowness and silent loss, not only errors. |
The SMS provider times out on a login code. What do you do?
I don't know whether it was sent, so I retry. For login codes a duplicate is far better than a missing one. If the circuit breaker shows this provider is failing broadly, the retry goes to our second provider, which may mean two texts. The code expires in 5 minutes, so the job has that deadline; after it, retrying is pointless.
And for a marketing text?
Different answer. A duplicate costs money and annoys people, so I'd retry only the same provider with its message id, and accept losing a few.
Follow-up questions
How do you send a campaign to 50 million users?
Do not make 50 million API calls. A campaign job reads the segment in pages, creates notifications in batches, and feeds the bulk queues at a set pace, 28,000 a second for 30 minutes. It records progress so it can resume after a crash without starting over.
How do you handle device tokens that stop working?
Platform push services report invalid or expired tokens. Delete those tokens at once; sending to dead tokens wastes capacity and can count against you with the platform.
A user gets the same alert from three services. How do you stop it?
Add a short dedupe window per user and topic in the router: if an alert with the same group key went out in the last 10 minutes, merge or skip it. Better, give each event one owner that calls the notification service.
How do you send at 9 a.m. in each user's time zone?
Store scheduled notifications by send time, rounded to the minute. A scheduler reads the current minute's bucket and enqueues it. Users spread across time zones spread the load naturally.
How do you know the system is healthy?
Track queue age per channel and priority (how old is the oldest job), send and delivery rates per provider, and retry and dead-letter counts. Alert on high-priority queue age above a few seconds; see Observability.
After your attempt, tick what you really did. Be strict: "I thought about it" is not "I said it out loud".