Design a Notification System
Push, email, and SMS each have their own providers, quotas, and failure modes. The design keeps one slow provider from blocking the others and makes sure nobody gets the same alert twice.
1 · Requirements
The Prompt
Send push, email, and SMS notifications to millions with per-user preferences, deduplication, and retries.
Functional
- Send notifications by push, email, and SMS from one API
- Respect each user's channel preferences and quiet hours
- Deduplicate repeat requests
- Retry failures and record delivery status
Non-Functional
- One failing provider never delays the other channels
- Each dedup key sends at most one notification
- Handles about 60,000 sends a second at peak
2 · Estimates
Drill This Phase First
Show the Answer and Estimates
Answer
About 23,000 a second
200M times 10 is 2B notifications a day. 2B divided by 86,400 seconds is about 23,000 a second, and about 60,000 at peak.
- Sends
- 200M users at about 10 notifications a day: 2B a day, about 23,000 a second and 60,000 at peak
- Channels
- About 70% push, 20% email, and 10% SMS: 16,000 push, 4,600 email, and 2,300 SMS a second
- Quotas
- APNs limits connections and Twilio limits each number's send rate, so each provider gets its own worker pool and token bucket
- Dedup
- 24 hours of user and key hashes: about 2B entries at 24 bytes, roughly 50 GB in Redis
- Retries
- About 2% transient failures with up to 5 retries: plan for about 5% extra queue traffic
- Prefs
- 200M users at about 200 bytes each: about 40 GB in a key-value store, sharded by user_id
3 · API
POST /notify { user_id, template_id, vars, channels[], dedup_key? } → { notif_id, status: queued }
GET /notifications/:id → { channel_attempts[], final_status }
PUT /users/:id/preferences { channels: { push: true, email: false }, quiet_hours } → 204
POST /devices/register { user_id, platform, token } → 204
Webhook: POST /providers/:name/callback (delivery receipts) → 200Data Model
notifications (Cassandra, partition = user_id, cluster = ts DESC):
notif_id (PK) : uuid
user_id : uuid
template_id : string
channel : enum(push|email|sms)
status : enum(queued|sent|delivered|failed|suppressed)
attempts : int
dedup_key : string?
created_at : timestamp
user_prefs (KV, key = user_id):
channels : map<channel, bool>
quiet_hours : { start, end, tz }
topics_muted : list<string>
dedup (Redis, key = "dd:{user_id}:{dedup_key}", TTL 24h):
value : notif_id4 · High-Level Design
5 · Deep Dives
Bulkheads and Circuit Breakers
Give each channel its own topic and worker pool, so a Twilio outage backs up SMS while push keeps flowing. Wrap each provider in a circuit breaker that stops calling after repeated failures and probes again after a cooldown, while messages wait in Kafka.
Deduplication
Producers send a dedup key with each request. Before sending, a worker sets dd:{user_id}:{dedup_key} in Redis only if it is absent, with a 24 hour TTL. A retry from the producer or a redelivered Kafka message then finds the key and skips the send.
Preferences and Quiet Hours
Check preferences at send time, since a user may opt out between queueing and sending. Hold notifications that land in quiet hours in a delayed queue keyed to the user's time zone, and drop them if they expire first.
6 · Wrap Up
The Short Version
Pub/sub fan-out by channel; per-channel worker pools (bulkhead); circuit breaker on external providers (APNs/FCM/Twilio); dedup key in a key-value store.