MediumProblem 20 of 36 in the system design series
Design a Notification System: Push, Email and SMS Without Duplicates

Almost every product eventually needs to tell people things: a parcel has shipped, a login looks suspicious, a sale starts tomorrow. Rather than letting each team wire up its own email library and push SDK, companies build a shared notification platform. Internal services describe what to say and to whom, and the platform figures out the channel, the wording, the timing and the delivery.
Interviewers like this problem because the happy path is trivial and everything interesting lives in the failure cases. External providers time out, return rate-limit errors or go down entirely. A retry that seemed harmless delivers a second one-time password. A campaign aimed at fifty million users floods the pipe just as someone is waiting for a login code.
A good answer therefore spends little time on the act of sending and most of its time on isolation between channels, prioritisation of urgent traffic, respecting user choices, and making retries safe. Those four concerns shape every box in the design that follows.
This is an interview practice design for a Notification System-like service, built from first principles. It is not a description of how Notification System is actually built.
Functional Requirements
The platform's first job is a single entry point: any internal team can request a push, an email or an SMS through the same call, without knowing which vendor sits behind it. Second, content comes from templates that are filled with per-user values and localised, so the checkout team never hard-codes copy in Hindi or Spanish inside its own service.
Third, users stay in control. They choose which channels they want, opt out of promotions, and define quiet hours. Fourth, the system supports both deferred delivery and large audience campaigns. We explicitly do not build carrier infrastructure; push goes through APNs and FCM, email through a service such as SES, and SMS through a third-party vendor. That boundary matters because it means our reliability depends on systems we cannot fix.
Send on any channel
Services send push, email or SMS through one simple API.
push · email · SMS
Templates
Messages are built from templates, with user data and language.
versioned · i18n
User preferences
Users pick channels, opt out, and set quiet hours.
opt-outquiet hours
Scheduled & bulk sends
Send now, later, or to a whole audience at once.
campaigns
- Out of scope: running our own carrier — we use APNs, FCM, SES and SMS vendors
Non-Functional Requirements
Reliability comes first: once the API has accepted a request, the message must either be delivered or end in a visible failure state, never vanish. That pushes us toward at-least-once processing, which in turn creates the duplicate problem solved later. Latency is selective. A login code has to land in seconds even while a huge promotion is in flight, whereas a newsletter can tolerate minutes.
Users must not be spammed, so the same message should never show up twice and promotional volume is capped per person. Finally, load arrives in spikes rather than a smooth curve. A marketing team pressing send on a big campaign can generate millions of messages in a few minutes, so every stage must scale horizontally and absorb bursts with buffering instead of falling over.
Reliable
An accepted notification is never silently lost.
at-least-once
Fast when it matters
OTPs and security alerts arrive within seconds.
priority lanes
No duplicates, no spam
The same message never arrives twice; caps limit noise.
dedupe · caps
Handles big bursts
A campaign can mean millions of messages in minutes.
horizontal scale
Scale Estimation
Assume about 1 billion notifications per day, split roughly 70% push, 25% email and 5% SMS. SMS is the smallest share but by far the costliest per message, which is why later sections care so much about not sending it twice. Spread evenly, a billion a day is around 12,000 sends per second.
Averages hide the real problem. Reaching 50 million people within ten minutes lifts throughput to around 80,000 per second, several times the mean. If each message leaves about 500 bytes of log data, the log grows by around 500 GB per day, or roughly 15 TB for thirty days of retention. These figures argue for durable queues in front of every stage, separated by channel, so bursts are smoothed and one provider's trouble stays contained.
1billion
Notifications per day
push 70% · email 25% · SMS 5%
12K avg
Sends per second
≈ 80K/s during a campaign
500GB / day
Log (500 B / message)
≈ 15 TB for 30 days
Bursty, and spread over many providers → queue everything, per channel
Core Entities & API
Four entities carry the model: a Notification (the request), a Template, a User preference record, and a Delivery, which represents one attempt on one channel. Splitting Delivery from Notification is what lets us report that push succeeded while SMS is still retrying.
Callers use POST /notifications with a user ID, template ID, data for the placeholders and an idem_key. The server answers 202 Accepted with a notification ID immediately instead of waiting for providers, which keeps upstream services fast. GET /notifications/{id} returns status per channel for debugging and support. Users manage their settings through PUT /users/{id}/preferences, which stores channel choices, opt-outs and quiet hours.
Core entities
NotificationTemplateUser preferenceDelivery
API
- POST /notifications { user_id, template_id, data, idem_key }→ 202 { notification_id }
- GET /notifications/{id}→ { status per channel }
- PUT /users/{id}/preferences { channels, opt_outs, quiet_hours }→ 200 saved
High-Level Design
A request enters through the notification API, which validates input and rejects repeats using the caller's idempotency key. It writes a record to the notification log and publishes an event to Kafka, then returns. Everything after that point is asynchronous, so a slow vendor can never block an order or payment flow.
A router consumes the event, loads the user's preferences and the template (both cached in Redis in front of a database), picks the eligible channels and renders the final text. It then places one message on each relevant channel queue. Push, email and SMS have independent queues and worker pools, so an email outage does not hold up push. Workers call the external provider and report the outcome back to the log, giving callers and support staff a complete delivery trail.
Deep Dive 1 — Routing & Preferences
Routing is where policy lives. Time-sensitive traffic such as OTPs and security warnings rides a dedicated high-priority lane with its own workers, while promotions sit in a lower lane that can lag without harm. Before any rendering happens, the router checks preferences from Redis, because there is no point formatting a message the user has opted out of, and in many regions sending marketing without consent is a legal issue.
Quiet hours and frequency caps come next. A non-urgent push generated at 1 a.m. in the user's timezone is parked until morning, and promotions are limited to a small number per day. Templates are versioned, so a copy change ships as a new version without breaking services pinned to the old one. Campaigns never hit the router as one giant request; a separate fan-out job slices the audience into modest batches and feeds them in steadily.
Priority lanes
OTPs and security alerts use a high-priority queue; marketing waits in a low lane.
Check preferences first
Opt-outs and channel choices, cached in Redis; marketing needs consent.
Quiet hours & frequency caps
Non-urgent messages wait for morning; at most a few promos per day.
Versioned templates
Rendered per language with user data; new versions never break callers.
Campaign fan-out
A separate job splits a 50M-user campaign into small batches.
Deep Dive 2 — Retries & Rate Limits
Every vendor publishes limits, so each one gets a token bucket stored in Redis and shared across all workers. A worker sends only when it can take a token, which keeps the fleet under the cap regardless of how many workers are running. When a call fails, the error is classified. Timeouts, HTTP 429 and 5xx responses are transient and worth retrying; an invalid phone number or bounced address is permanent and should stop immediately.
Retries use exponential backoff with jitter and are scheduled through a delay queue, so no worker thread sits idle waiting. If a provider keeps failing, a circuit breaker trips and traffic shifts to a backup vendor, which is especially valuable for SMS codes. Messages that exhaust their attempts land in a dead-letter queue for inspection, and when a push service reports a device token as unregistered, that token is removed so we stop wasting calls on it.
- 1Token bucketOne per provider, so we stay under its send limit
- 2Classify the errorTimeout, 429, 5xx → retry; bad number → stop
- 3Backoff with jitterRetry later through a delay queue, not a busy loop
- 4Fail overCircuit opens → switch to a backup vendor
- 5Dead-letter & clean upGive up after N tries; delete dead device tokens
Deep Dive 3 — No Duplicates
Skipping deduplication is simplest, but with at-least-once processing it guarantees that some users eventually receive a second code. Checking the idempotency key only at the API blocks duplicate requests from callers, yet it does nothing for the worker that sends successfully, crashes before acknowledging, and is retried by the queue.
The chosen approach checks at both points. Right before calling a provider, a worker attempts SET NX in Redis on a key made of the notification ID and channel, with a 24-hour TTL. If the key already exists, the send is skipped. The remaining risk is a worker dying in the instant between the provider call and the key write, so push messages also carry a collapse key; if a duplicate slips through, the device simply replaces the older copy with the newer one.
No dedupe
- + Simplest
- − Retries send double OTPs
Dedupe at the API
- + Stops caller retries
- − Worker retries still dupe
Keys at API + sendChosen
- + Covers both retry paths
- + Small Redis state
- − Extra check per send
- Send key = (notification_id, channel) · SET NX with a 24 h TTL
- Push collapse keys: a newer push replaces the older one
Trade-offs
A queue per channel isolates failures at the cost of more infrastructure to operate, and that cost is worth paying because cross-channel stalls are the most visible outage. At-least-once processing with deduplication means nothing silently disappears, but every hop needs an idempotency key and the discipline to check it.
Quiet hours make users happier and reduce opt-outs, while intentionally delaying non-urgent messages. Integrating several vendors per channel gives resilience and pricing leverage but multiplies the integration and monitoring work. The guiding rule: isolate channels, honour what the user asked for, and design retries so that repeating an operation can never deliver the same message twice.
One queue vs a queue per channel
Per-channel queues isolate failures — but there are more systems to run.
At-least-once + dedupe
Nothing is lost — but we need idempotency keys at every step.
Quiet hours vs speed
Happier users — but non-urgent messages are delayed on purpose.
One provider vs several
Backups add resilience and price options — but more integrations.
Queue per channel. Respect the user. Retry safely, never twice.
Key takeaways
- Accept fast with 202 and do the rest asynchronously through Kafka and per-channel queues.
- Separate priority lanes keep OTPs and security alerts quick even during big campaigns.
- Check preferences, quiet hours and frequency caps in the router before rendering any message.
- Token buckets, error classification, backoff, circuit breakers and dead-letter queues tame flaky providers.
- Deduplicate at the API and again before each send; push collapse keys cover the last gap.
Interview questions
How do you prevent duplicate notifications in a distributed system?
Use idempotency keys at two layers. The API rejects repeat requests that carry the same caller key, and each worker performs an atomic set-if-not-exists in Redis on notification ID plus channel just before sending, with a TTL of about a day. If the key already exists the send is skipped. For push, collapse keys cover the rare crash window by letting the device keep only the newest copy.
Why use separate queues for push, email and SMS notifications?
Each channel depends on a different external provider with its own speed, limits and failure modes. With one shared queue, a slow or failing email vendor would back up messages for every channel, including urgent push alerts. Separate queues and worker pools keep problems contained, let each channel scale independently, and allow different retry and rate-limit settings per provider.
How should a notification service handle provider rate limits?
Keep a token bucket per provider in a shared store such as Redis so every worker draws from the same budget. Workers send only after acquiring a token. When a provider still returns 429 or 5xx errors, retry with exponential backoff and jitter via a delay queue, and if failures persist, open a circuit breaker and route traffic to a backup vendor.
How do OTPs stay fast during a large marketing campaign?
Give urgent traffic its own high-priority queue and dedicated workers so it never waits behind bulk messages. Campaigns are broken into small batches by a fan-out job and fed into a low-priority lane, which can lag without harm. Combined with per-channel isolation, this means a fifty-million-user promotion does not delay a login code by more than a few seconds.
Where are user notification preferences and quiet hours enforced?
In the router, before rendering or queuing anything. It reads opt-outs, channel choices and quiet hours from a Redis cache backed by a database, drops messages the user has declined, holds non-urgent ones until morning in their timezone, and applies daily caps on promotions. Enforcing it centrally means no individual service can accidentally bypass a user's choices.
Keep practising
- Learn the building blocks in the free system design course.
- Rehearse answers with system design interview questions.
- See which open jobs fit your skills: upload your resume.