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.

Case study32 minLesson 6 of 7
On this page
  1. Clarify the problem
  2. Estimate the scale
  3. Define the API
  4. Model the data
  5. High-level design
  6. Deep dives
  7. Bottlenecks and failures
  8. What a strong answer sounds like
  9. Follow-up questions

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

The first five minutes
Interviewer

Design a notification system.

You

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?

Interviewer

Yes to both. Other services call you. Providers do the last mile.

You

What kinds of messages: one-off alerts like login codes and order updates, or also marketing campaigns to millions of users at once?

Interviewer

Both. Campaigns can target 50 million users.

You

Volumes per day? And how fast must a login code arrive?

Interviewer

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.

You

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.
Estimate: A login code stuck behind a campaign

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?

You know
  • 50 million campaign pushes already in the queue
  • Senders drain the queue at 28,000 pushes a second

Show the working
  1. 50,000,000 ÷ 28,000 ≈ 1,800 seconds
  2. 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.

Estimate: Notification log for 90 days

How much storage does a 90-day log of every notification take, before replication?

You know
  • 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
  1. 605 × 106 × 500 B ≈ 300 GB a day
  2. 300 GB × 90 ≈ 27 TB

Estimate 27 TB (anything from 8 TB to 80 TB is a good answer)

Your turn: Define the API

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

HTTP 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 providers

The 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.

Your turn: Model the data

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

Records
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_seen

The 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.

Your turn: High-level design

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.

The API accepts and records; routers decide who gets what on which channel; each channel has its own queue (dashed arrows) and its own senders talking to one provider.Calling services to Notification API (POST); Notification API to Notification log (record, dedupe); Notification API to Routers (intake queue), asynchronous; Routers to Preferences and devices (allowed?); Routers to Push senders (push queue), asynchronous; Routers to SMS senders (SMS queue), asynchronous; Routers to Email senders (email queue), asynchronous; Push senders to Platform push services; SMS senders to SMS provider; Email senders to Email provider.Calling servicesNotification APINotification logRoutersPreferences anddevicesPush sendersSMS sendersEmail sendersPlatform pushservicesSMS providerEmail providerPOSTrecord, dedupeintake queueallowed?push queueSMS queueemail queue

request and replyasynchronous message

The API accepts and records; routers decide who gets what on which channel; each channel has its own queue (dashed arrows) and its own senders talking to one provider.

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.

Order 881 has shippedUse the arrows or ← → keys
  1. Order 881 has shipped, step 1Order service to Notification API; Notification API to Notification log; Notification API to Router, asynchronous; Router to Preferences and devices; Router to Push senders, asynchronous; Router to SMS senders, asynchronous; Router to Email senders, asynchronous; Push senders to Platform push services; SMS senders to SMS provider; Email senders to Email provider.Order serviceNotification APINotification logRouterPreferences anddevicesPush sendersSMS sendersEmail sendersPlatform pushservicesSMS providerEmail provider

    1The order service calls POST /v1/notifications for user 42 with key order-881-shipped, priority normal.

  2. Order 881 has shipped, step 2Order service to Notification API; Notification API to Notification log; Notification API to Router, asynchronous; Router to Preferences and devices; Router to Push senders, asynchronous; Router to SMS senders, asynchronous; Router to Email senders, asynchronous; Push senders to Platform push services; SMS senders to SMS provider; Email senders to Email provider.Order serviceNotification APINotification logn_501 queuedRouterPreferences anddevicesPush sendersSMS sendersEmail sendersPlatform pushservicesSMS providerEmail provider
    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.

  3. Order 881 has shipped, step 3Order service to Notification API; Notification API to Notification log; Notification API to Router, asynchronous; Router to Preferences and devices; Router to Push senders, asynchronous; Router to SMS senders, asynchronous; Router to Email senders, asynchronous; Push senders to Platform push services; SMS senders to SMS provider; Email senders to Email provider.Order serviceNotification APINotification logRouterPreferences anddevicesPush sendersSMS sendersEmail sendersPlatform pushservicesSMS providerEmail provider
    rows for key
    1

    3It puts n_501 on the intake queue and answers 202. The caller waited a few milliseconds, not for any provider.

  4. Order 881 has shipped, step 4Order service to Notification API; Notification API to Notification log; Notification API to Router, asynchronous; Router to Preferences and devices; Router to Push senders, asynchronous; Router to SMS senders, asynchronous; Router to Email senders, asynchronous; Push senders to Platform push services; SMS senders to SMS provider; Email senders to Email provider.Order serviceNotification APINotification logkey existsRouterPreferences anddevicesPush sendersSMS sendersEmail sendersPlatform pushservicesSMS providerEmail 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_501 again. Nothing new is queued.

  5. Order 881 has shipped, step 5Order service to Notification API; Notification API to Notification log; Notification API to Router, asynchronous; Router to Preferences and devices; Router to Push senders, asynchronous; Router to SMS senders, asynchronous; Router to Email senders, asynchronous; Push senders to Platform push services; SMS senders to SMS provider; Email senders to Email provider.Order serviceNotification APINotification logRouterPreferences anddevicespush, emailPush sendersSMS sendersEmail sendersPlatform pushservicesSMS providerEmail provider
    rows for key
    1

    5A router picks up n_501 and reads user 42's settings from the cache: order updates by push and email, not SMS. Two phones are registered.

  6. Order 881 has shipped, step 6Order service to Notification API; Notification API to Notification log; Notification API to Router, asynchronous; Router to Preferences and devices; Router to Push senders, asynchronous; Router to SMS senders, asynchronous; Router to Email senders, asynchronous; Push senders to Platform push services; SMS senders to SMS provider; Email senders to Email provider.Order serviceNotification APINotification logRouterPreferences anddevicesPush sendersSMS sendersEmail sendersPlatform pushservicesSMS providerEmail provider
    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.

  7. Order 881 has shipped, step 7Order service to Notification API; Notification API to Notification log; Notification API to Router, asynchronous; Router to Preferences and devices; Router to Push senders, asynchronous; Router to SMS senders, asynchronous; Router to Email senders, asynchronous; Push senders to Platform push services; SMS senders to SMS provider; Email senders to Email provider.Order serviceNotification APINotification logRouterPreferences anddevicesPush senders2 sentSMS sendersEmail sendersPlatform pushservicesSMS providerEmail provider
    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.

  8. Order 881 has shipped, step 8Order service to Notification API; Notification API to Notification log; Notification API to Router, asynchronous; Router to Preferences and devices; Router to Push senders, asynchronous; Router to SMS senders, asynchronous; Router to Email senders, asynchronous; Push senders to Platform push services; SMS senders to SMS provider; Email senders to Email provider.Order serviceNotification APINotification logRouterPreferences anddevicesPush sendersSMS sendersEmail senderstimeoutPlatform pushservicesSMS providerEmail provider
    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.

  9. Order 881 has shipped, step 9Order service to Notification API; Notification API to Notification log; Notification API to Router, asynchronous; Router to Preferences and devices; Router to Push senders, asynchronous; Router to SMS senders, asynchronous; Router to Email senders, asynchronous; Push senders to Platform push services; SMS senders to SMS provider; Email senders to Email provider.Order serviceNotification APINotification logRouterPreferences anddevicesPush sendersSMS sendersEmail sendersPlatform pushservicesSMS providerEmail provideraccepted
    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.

  10. Order 881 has shipped, step 10Order service to Notification API; Notification API to Notification log; Notification API to Router, asynchronous; Router to Preferences and devices; Router to Push senders, asynchronous; Router to SMS senders, asynchronous; Router to Email senders, asynchronous; Push senders to Platform push services; SMS senders to SMS provider; Email senders to Email provider.Order serviceNotification APINotification logRouterPreferences anddevicesPush sendersSMS sendersEmail sendersPlatform pushservicesSMS providerEmail provider
    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.

1 / 10

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.

How to lay out the queues
OptionGood atBad atPick it when
One queue for everythingOne 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 channelChannels 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 defaultHigh-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.
Inside the email channel: two queues by priority feed one sender pool. Failed sends wait in a retry queue; ones past their deadline go to a dead-letter queue for a person to inspect.Routers to Email: high, asynchronous; Routers to Email: bulk, asynchronous; Email: high to Email senders (reserved share); Email: bulk to Email senders (throttled); Email senders to Email provider A (primary); Email senders to Email provider B (failover); Email senders to Retry queue (retry later), asynchronous; Retry queue to Dead letters (past deadline).RoutersEmail: highEmail: bulkEmail sendersEmail provider AEmail provider BRetry queueDead lettersreserved sharethrottledprimaryfailoverretry laterpast deadline

request and replyasynchronous message

Inside the email channel: two queues by priority feed one sender pool. Failed sends wait in a retry queue; ones past their deadline go to a dead-letter queue for a person to inspect.

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 deliveries row; skip it if it already says sent. 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.

What to do when a provider fails
OptionGood atBad atPick it when
Retry the same providerSimple; 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 defaultKeeps 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 timeBoth 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 diesWhat happensWhat you do
The APICallers get errors or timeouts.Callers retry with the same idempotency key; run several API instances behind a load balancer.
A router or senderIts in-flight jobs are not acknowledged.The queue hands them to another worker after a timeout; the sent check limits duplicates.
The preference cacheRouters 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 providerSends fail or hang.The circuit breaker opens; fail over to the second provider; high priority first.
A platform push servicePush 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

LevelWhat the answer does
JuniorAn API that calls the providers directly, with a database of preferences. Adds a queue when asked about slow providers.
MidReturns 202, uses a queue per channel, checks preferences, retries with backoff, and mentions idempotency keys.
SeniorFinds 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.
A provider times outStrong answer
Interviewer

The SMS provider times out on a login code. What do you do?

You

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.

Interviewer

And for a marketing text?

You

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.

Self-review: notification system0 of 13

After your attempt, tick what you really did. Be strict: "I thought about it" is not "I said it out loud".

Before drawing

Numbers and interfaces

Design and depth

Failure