Chapter 10 · System Design Fundamentals
Design a Notification System
One service that reliably reaches every user across push, SMS, email, in-app, and web — at scale, on time, and only when wanted. The functional surface looks trivial ("send a message"); the design lives in the queue between intent and delivery, and in the preferences that decide whether to send at all.
▶ Open the companion slidesA notification system is not a "sender" — it is an orchestrator over a handful of channel-specific delivery contracts. Producers post one logical event; the system decides the channels, renders the copy, respects the user's preferences, absorbs the burst in a queue, and fans out to third-party providers that each fail in their own special way. Everything hard — spikes, duplicates, provider outages, consent — is about controlling that pipeline, not about the send itself.
Alex Xu, System Design Interview (Vol. 1), Chapter 10. Companion deck: slides — jump to any slide with the Slide N chips. Go deeper: the AWS SNS Developer Guide — a production pub/sub-to-many-channels service that mirrors this design.
Five channels, five contracts Slide 2
Start with the surfaces. Each channel carries its own provider, payload limit, identity model, and cost profile — and each has a different failure mode. Treating them uniformly inside the core and isolating the quirks at the edges is the central design challenge. Note the cost asymmetry: push and in-app are nearly free, SMS costs real money per message, email sits between.
| Channel | Provider | Identity | Cost / limit |
|---|---|---|---|
| Mobile push | APNs (iOS), FCM (Android) | Device token per install | ~free · ~4 KB payload |
| SMS | Twilio / MessageBird | E.164 phone number | cents each · 160 chars/segment |
| SendGrid / SES | Email address | cheap · reputation-gated | |
| In-app | Your own backend | User id + read state | free · no third party |
| Web push | Web Push / VAPID | Browser subscription URL | ~free · browser-gated |
A good system steers each notification to the cheapest channel that still satisfies the user's intent. It also keeps the channels independent so a slow email provider never starves push.
What the system must do Slide 3
Before drawing boxes, pin down the volume, the latency budget, and the rules the system must respect on the user's behalf. The functional list is short; the non-functional list is where the design pressure lives.
Functional
One API for many producers; deliver across all channels from a single logical request; per-channel and per-topic opt-outs; both transactional sends (one user, now) and scheduled campaigns (many users); server-rendered templates so non-engineers can edit copy.
Non-functional
Absorb bursty traffic (launches and incidents spike over a calm baseline); a few seconds end-to-end for transactional, minutes for campaigns; no duplicate deliveries for one logical event; survive a single provider's outage without dropping messages.
The gap between a 115/s average and a 5,000/s peak is the whole story — the system must absorb roughly a 40× surge without losing messages or melting providers. That is a queueing problem, and the same peak-vs-average reflex from Chapter 2 sizes the fleet.
A single front door, many delivery lanes Slide 4
Read the pipeline first — it is the system. Producers post a logical notification once to the Notification API, which authenticates, applies preferences, and renders a template. The message drops into a queue (one topic per channel) that absorbs the burst. Per-channel workers pull at their provider's allowed rate and translate to provider calls. Every delivery event streams back to a tracking store.
One API, server-rendered templates Slide 5
Callers describe what happened and who it concerns — a logical event, not a rendered message. The notification system decides how to reach them. That indirection is the point: copy changes and channel routing never require re-deploying every producer.
Template variables come from the request's data field plus a profile lookup (name, locale,
time zone). Rendering inside the service means the wording can change without an app release, and a
staging step catches a broken template before it reaches production.
A queue between intent and delivery Slide 6
Producers run on their own schedules; providers enforce their own rate limits. The queue is the shock absorber that keeps each side from breaking the other. A bursty input meets a steady, rate-limited output; the queue holds the difference so neither end has to flex unnaturally.
Producer-side win
The API returns success the moment the message lands in the queue. Producers never wait on the provider hop and never block when a provider is slow.
Consumer-side win
Workers pull at exactly their provider's allowed rate. One topic per channel means each worker fleet scales and tunes independently.
Providers — each is its own snowflake Slide 7
All the messy realities of the outside world live in the channel workers. Each worker integrates with one provider through a thin adapter and shields the rest of the system from its quirks — payload sizes, carrier filters, warm-up rules, rate-limit shapes. Above the adapter sits one uniform interface: send, cancel, status.
| Channel | Provider | Rate-limit shape & gotcha |
|---|---|---|
| Push iOS | APNs (HTTP/2) | Connection-level concurrency, no hard QPS — but back off on 429. |
| Push Android | FCM | Project-level QPS; multicast batches many tokens into one call. |
| SMS | Twilio / MessageBird | Per-sender QPS and country throttles; carrier filtering is separate. |
| SendGrid / SES | Daily quotas tied to reputation; new domains need a warm-up. | |
| Web push | VAPID endpoints | Per-endpoint, set by the browser; 410 means gone for good. |
Per-provider rate buckets
Each worker enforces its own token bucket — the same idea as Chapter 4. If the budget is 300 emails/s, the worker holds at 300 even when the queue holds millions; the queue absorbs the rest.
Failover providers
For SMS especially, a second provider per region keeps deliveries flowing during an outage. The worker tries the secondary after a few failed attempts on the primary — a contained refactor, not a rewrite.
Retries, backoff, and a dead-letter queue Slide 8
Providers fail; networks blip. The worker's job is to know which failures are worth retrying, how long to wait between tries, and what to do with messages that never go through. The rule: retry the transient, drop the permanent, and dead-letter the rest so nothing is ever silently lost.
Classify before retrying
A 4xx usually means the message itself is bad — wrong token, unverified sender, malformed payload. Retrying just burns quota. Only 5xx, timeouts, and explicit 429s are retried.
Backoff with jitter
Each retry waits roughly twice as long, with random jitter so a fleet of workers does not all re-hit a wounded provider on the same second. Bound attempts to five or six, then dead-letter.
Deduplication & idempotency Slide 9
Distributed systems retry. Caller scripts retry. Queues redeliver. The system must guarantee the user
sees each logical message at most once, no matter how many times its components try. The
tool is a stable idempotency key chosen by the producer — for a shipped order,
order-A-7710-shipped — checked at two layers.
idempotency_key, TTL 24–48h — a second request with the same key returns the original responseQueues and retries give you at-least-once delivery for free. The idempotency key is what turns that into at-most-once per (user, channel, event). Without it, one flaky provider response can fan out into three copies of the same push landing on a user's phone.
User preferences are a first-class gate Slide 10
Even a perfectly delivered notification is wrong if the user did not want it. Preferences are enforced centrally, in the request path — not as an afterthought filter — so every channel and every event respects them. Four gates run in order; any failure drops or defers the message before it reaches the queue.
On top sits a regulatory floor: GDPR, CAN-SPAM, and TCPA demand explicit consent for marketing channels, a working unsubscribe link in every email, and deletion of preference history on request. The preference service is the audit trail — the right notification at the wrong time is still a wrong notification.
Tracking — did it land, did it work? Slide 11
Every send emits a stream of events — delivered, opened, clicked — that flow into one pipeline. Without this loop the system runs blind and channel routing stays stuck on guesswork. The same event stream feeds dashboards, the warehouse, the frequency-cap counters, and the token-cleanup job.
Principles Slide 12
Decouple with a queue
The queue is the heart of the design. Producers stay fast, providers stay within limits, and the system survives spikes neither end could absorb alone.
Push quirks to the edges
Only the channel workers know APNs payload sizes, Twilio carrier filters, or SES warm-up. Everything inside speaks one clean shape.
Retry the transient, dead-letter the rest
Backoff with jitter for 5xx and timeouts; a DLQ with tooling and alerts for the rest. Never silently drop a message.
Idempotency ends at-least-once
A stable key per logical event, checked at the API and again at the worker. The user sees the message at most once even after five retries.
Preferences are a gate
Opt-outs, topic mutes, quiet hours, and frequency caps live in the request path. The right notification at the wrong time is still wrong.
Close the loop with tracking
Every delivery emits an event that powers dashboards, frequency caps, token cleanup, and the routing that picks the best channel next time.
Active recall
Cover the answers. Say each one out loud before you tap to check.
Check yourself
4xx "invalid device token" from APNs. What should it do?