SYSTEM DESIGN · DESIGN PROBLEMS · PLATFORM AND ASYNC · MEDIUM · 37 OF 41

Design a Notification System: Push, SMS and Email at a Billion a Day

How to design a shared notification service that any team can call: one API for push, SMS, email and in-app, user preferences and quiet hours, templates, separate priority queues so a marketing blast never delays a login code, per-provider rate limits, retries with backoff, deduplication, dead device tokens, bulk campaign fan-out and delivery tracking.

Reported atAppleLinkedInAirbnbAtlassianGoldman SachsFlipkartRazorpay

  1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. 6
  7. 7
  8. 8
  9. 9
  10. 10
  11. 11
  12. 12
  13. 13
  14. 14
  15. 15
  16. 16
  17. 17
  18. 18
  19. 19
  20. 20
  21. 21
  22. 22
  23. 23
  24. 24
  25. 25
  26. 26
  27. 27
  28. 28
  29. 29
  30. 30
  31. 31
  32. 32
  33. 33
  34. 34
  35. 35
  36. 36
  37. 37
  38. 38
  39. 39
  40. 40
  41. 41
1 / 18

Press play to hear the narration, or step through at your own pace.

Notes

Listen

Listen. Internal services send to a user on push, SMS, email or an in-app inbox, now or at a scheduled time, to one user or a segment of millions. Messages come from templates with variables and languages. We must respect opt-outs and quiet hours, and track sent, delivered and opened. Building the providers themselves is out of scope.

The qualities. Login codes and security alerts must arrive within about five seconds, even during a huge marketing send. Marketing can wait minutes. Internally we can deliver at least once, but the user should see each message only once. Opt-outs are a legal requirement, so never break them. And a provider outage must not stop critical messages.

Functional
  • channels: push, SMS, email,
  • in-app inbox
  • send now or at a set time
  • one user, or a segment of
  • millions (campaigns)
  • templates with variables
  • and languages
  • respect opt-outs and quiet
  • hours
  • track sent, delivered, opened
Non-functional
  • priority: OTP and security
  • alerts in ≤ 5 s, always
  • marketing can wait minutes
  • at-least-once internally,
  • but users see it once
  • never break opt-outs (legal)
  • survive a provider outage
  • protect users from spam

Ask scale

Scale. A hundred million daily users getting ten notifications each is a billion a day, about ten thousand a second. One campaign to fifty million users in an hour adds fourteen thousand a second, so plan for a peak around fifty thousand. The delivery log is about half a terabyte a day; keeping thirty days is fifteen terabytes.

Back of the envelope
100M daily users
10 notifications/user/day
  = 1B/day ≈ 10,000/s average

campaign: 50M users in 1 hour
  ≈ 14,000/s extra
peak ≈ 50,000/s

delivery log: 1B × 500 B
  = 500 GB/day, keep 30 days
  ≈ 15 TB

Define API

The API. One send call takes an idempotency key, a user or a segment, a template and its parameters, a priority and an optional send time. It returns two oh two accepted, because delivery is asynchronous. Other endpoints read status, update preferences, register device tokens, and receive delivery receipts from providers as webhooks.

API
POST /v1/notifications
  { idempotency_key, user_id |
    segment_id, template_id,
    params, priority, send_at }
  → 202 { notification_id }

GET  /v1/notifications/{id}
PUT  /v1/users/{id}/preferences
POST /v1/users/{id}/devices
POST /v1/webhooks/{provider}

Data model

Data model. Devices, preferences and templates are modest, relational and read constantly, so keep them in SQL with a cache in front. Preferences are per category and channel, plus quiet hours and a time zone. The delivery log is huge and append-heavy, so it goes in a wide-column store like Cassandra, keyed by notification id.

Data model
devices (SQL)
  user_id, token, platform, seen
preferences (SQL)
  user_id, category, channel, on
  quiet_hours, timezone
templates (SQL)
  id, version, channel, locale

deliveries (Cassandra)
  PK notification_id, channel
  status, provider_msg_id, ts

High-level design

The high-level design. The API validates the request, checks the idempotency key, looks up preferences and device tokens, renders the template, and puts one job per channel on a queue. It replies right away. Channel workers pull jobs and call the right provider: Apple and Google push services, an SMS gateway or an email service.

Two more pieces. Workers record every attempt in the delivery log, and providers call back later with delivered, bounced or opened, which updates the same record. A scheduler holds messages with a future send time and campaign jobs, and releases them onto the queues when they are due, in the user's own time zone.

Deep dive: priority

Deep dive one: priority. With a single queue, a fifty million message sale campaign goes in at eight pm, and a login code sent one second later waits behind all of it, forty minutes. The user cannot log in. This is the most common failure in real notification systems, and the fix is isolation.

Give each priority and channel its own queue and its own workers. Critical, like login codes and security alerts, has dedicated workers that bulk traffic can never use. Normal covers order updates. Bulk is marketing, and is rate-capped. Providers have their own limits, say five hundred SMS a second, so a token bucket per provider reserves a share for critical messages.

Isolate by priority
  • queues: {critical, normal,
  • bulk} × {push, sms, email}
  • critical: OTP, security
  • own workers, never shared
  • normal: transactional
  • bulk: marketing, rate-capped
  • per-provider token bucket
  • e.g. SMS gateway 500/s
  • critical gets a reserved share

Deep dive: reliability

Deep dive two: reliability. Timeouts and five hundred errors are retried with exponential backoff and jitter, so retries do not stampede. If a provider keeps failing, a circuit breaker switches critical SMS to a backup provider, see Concepts episode 40. Permanent errors, like an invalid number, are not retried. After the maximum attempts, the job goes to a dead-letter queue for inspection.

Queues deliver at least once, so the same job can arrive twice. Deduplicate at two points. At the API, a repeated idempotency key returns the existing notification. In the worker, skip a message whose sent marker exists, and set the marker once the provider accepts it; a crash in between can rarely repeat one. Concepts episode 29 covers this. Push also supports a collapse key, so a duplicate replaces rather than stacks.

A quieter problem: dead device tokens. People uninstall apps and switch phones. Apple and Google answer with unregistered or a gone status for those tokens. Delete them right away, and expire tokens not seen in a couple of months. Otherwise a large share of your push traffic is wasted, and providers may throttle you.

Retries and failover
  • transient (timeout, 5xx):
  • retry, exponential backoff
  • + jitter: 1 s, 2 s, 4 s …
  • provider down: circuit
  • breaker → backup provider
  • permanent (bad number,
  • 410 token): no retry
  • max attempts → dead-letter
  • queue, alert, inspect
Deduplication
API: idempotency_key → id
  (unique index, 24 h)
  same key again → same id

worker, before sending:
  EXISTS sent:{id}:{ch} → skip
after the provider accepts:
  SET sent:{id}:{ch} EX 86400
crash in between: rare repeat
push: collapse key, so a
  retry replaces, not repeats

Deep dive: campaigns

Deep dive three: campaigns. A segment of fifty million users is resolved to a list of ids in object storage and split into batches of a thousand. Each batch job drops opted-out users, holds back anyone in quiet hours until morning in their time zone, applies a frequency cap such as two marketing pushes a day, renders the template and enqueues on the bulk queues, paced to finish in an hour.

Bulk fan-out
  • segment → user id list (S3)
  • split into 1,000-user batches
  • each batch job:
  • drop opted-out users
  • respect quiet hours (tz)
  • frequency cap: ≤ 2
  • marketing pushes per day
  • render per locale
  • enqueue on bulk queues
  • pace: finish in 1 hour

Trade-offs

Failure modes. A provider outage trips the breaker and critical traffic moves to a backup. A queue backlog pauses bulk sends while critical continues. If the preferences store is down, use cached preferences, and when unsure, skip marketing but still send transactional messages. A crashed worker's job reappears after its visibility timeout, and the sent marker prevents a repeat.

Monitor the age of the oldest message in each queue, the p ninety-nine time to deliver a login code, provider errors and latency, delivery and open rates, and spikes in opt-outs, which mean you are annoying people. At ten times the volume, add queue partitions and workers, contract several providers per channel, and deploy per region.

Failure modes
  • provider outage → breaker,
  • backup provider for critical
  • queue backlog → pause bulk,
  • critical keeps flowing
  • prefs DB down → cached prefs;
  • if unknown, skip marketing,
  • still send transactional
  • worker crash → job reappears
  • (visibility timeout) → dedupe
Monitor and grow
  • monitor:
  • queue age per priority
  • OTP time-to-deliver p99
  • provider error rate, latency
  • delivery and open rates
  • opt-out spikes (spam signal)
  • at 10x:
  • more partitions and workers
  • multiple providers per channel
  • regional deployments

Say it like this

In the interview: one API that accepts quickly with an idempotency key. Preferences and templates in SQL, the delivery log in a wide-column store. Queues per priority and channel, with dedicated workers for critical messages, and a token bucket per provider. Retries with backoff, circuit breakers and backup providers, a sent marker to avoid repeats, and campaigns that are batched, capped and paced.

Interview summary
  • one API, 202 + idempotency key
  • prefs + templates in SQL,
  • delivery log in Cassandra
  • queues per priority × channel
  • critical has its own workers
  • token bucket per provider
  • retry with backoff, breaker,
  • backup provider, DLQ
  • dedupe: sent marker per msg
  • campaigns: batch, cap, pace

Interview questions

How do you make sure a marketing campaign does not delay one-time passwords?

Use separate queues and dedicated worker pools per priority, so critical messages such as OTPs never share capacity with bulk traffic, and reserve part of each provider's rate limit for them.

How do you avoid sending the same notification twice?

Deduplicate at the API with an idempotency key and in the worker by checking a sent marker per message and channel before sending and setting it once the provider accepts, since queues deliver at least once; push collapse keys absorb the rare repeat after a crash.

What happens when an SMS provider goes down?

Retries back off exponentially with jitter, a circuit breaker stops calling the failing provider, and critical messages fail over to a backup provider while bulk traffic is paused.

How do you handle user preferences and quiet hours?

Store preferences per category and channel with the user's time zone, check them before enqueuing, and hold non-urgent messages that fall in quiet hours until the user's local morning.

How would you send a campaign to 50 million users?

Resolve the segment to a list of user IDs, split it into batches processed by parallel jobs that filter opt-outs, apply frequency caps and quiet hours, render templates, and enqueue onto rate-limited bulk queues paced over the send window.

Why is the send API asynchronous?

Delivery depends on slow external providers and retries, so the API validates and enqueues the request, returns 202 with a notification ID, and callers check status or receive callbacks later.

What do you do about invalid device tokens?

Delete tokens as soon as push services report them as unregistered and expire tokens not seen for a long period, which saves capacity and avoids being throttled by the providers.

What would you monitor in a notification system?

Queue age per priority, OTP time-to-deliver at p99, provider error rates and latency, delivery and open rates, dead-letter queue size and opt-out spikes.