YeetCode
System Design · Core Designs

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.

11 min readBy Bhavesh Singh
notification systemmessage queuefan-outidempotencypush notificationsretry backoff

This article has an interactive companion

Don't just read it — step through it interactively, one state change at a time.

Practice this topic on YeetCode

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.

text
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 DB

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

sql
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:

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

StepComponentState / actionResult
1API serviceValidate event evt-9f2, write row QUEUED, publish to events topic202 Accepted
2API service (retry)Duplicate event evt-9f2 arrives, same key hash(evt-9f2, 88231, push)UNIQUE violation → dropped
3DispatcherLoad prefs: push enabled:true, email enabled:falseOnly push survives
4DispatcherFetch device token, render template, enqueue to push queue1 message queued
5Push workerPull message, call FCM, get provider_msg_id: fcm-77aRow → SENT
6Status handlerFCM webhook reports delivered for fcm-77aRow → 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_id hash 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:

ControlMechanismWhat it prevents
Exponential backoff + jitterRetry at 1s, 2s, 4s, 8s ±20%Synchronized retry spikes
Circuit breaker per providerTrip on error-rate threshold, cool down, divert to fallbackHammering a sick provider
Capped attempts → DLQAfter N tries, route to dead-letter queueInfinite 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

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.

Practice this topic on YeetCode