Handbooks / System Design Case Studies / Chapter 5
Design a Notification System
12 pages · ~18 min✓ Reviewed
Builds on Design Search Autocomplete. Next up: Design a Chat App like WhatsApp.
Problem Statement, Scope and Requirements
1. Scope
Running example: a multi-channel Notification System for an app with 10M daily active users.
- Channels: push (APNs/FCM), SMS, email, in-app
- Triggers: service call, scheduled, bulk campaign
- Out of scope: marketing UI
- Out of scope: content authoring
2. Functional Requirements
- Send a single or a bulk notification
- Template rendering with variables
- Per-user opt-in/out by channel and by category
- Scheduling for a future time
- Delivery status query by notification id
3. Estimates and Latency Targets
| Metric | Estimate | Working |
|---|---|---|
| Volume | 50M/day | 10M DAU x 5 per user |
| Average QPS | about 580 | 50M / 86,400 s |
| Peak QPS | about 5k |
High-Level Architecture (Notification System)
1. End-to-End Flow
- Producerservice
- Notification APIreturns 202
- Validate + dedup + prefsintake checks
- Kafka topicschannel x priority
- Channel workersrender, limit, send
- Provider adaptersone per vendor
- ProviderFCM, Twilio, SES
Notification API and Intake Service
1. Responsibilities and API
Intake for the Notification System: ~5k QPS peak, accept is synchronous, delivery is asynchronous.
- Authenticate the producer service
- Validate the payload: user_id, template_id, channel[], priority
- Assign the notification_id on the server
- Enqueue durably, then return 202 with the id
GET /v1/notifications/{id}returns the current status
POST /v1/notifications
{"user_id": "u_42", "template_id": "otp_login",
"channel": ["sms", "push"], "priority": "critical",
"idempotency_key": "login-9f3a",
"data": {"code": "481516"}}202 Accepted -> {"notification_id": "..."}
Templates and Localization Service
1. Responsibilities and Render Path
- Store versioned templates per channel and locale
- Render variables into subject and body
- Enforce size limits: SMS 160 GSM-7 chars, push payload 4 KB
- Render at worker time, not at intake: the API only stores
template_id+data - Worker input:
template_id+version+data
- Workertemplate_id + version + data
- In-memory cachecompiled, TTL 5 min
- Compileon miss only
- Render + size check160 chars / 4 KB
User Preferences and Opt-Out
1. Responsibilities
- Per-user, per-channel, per-category opt-in (push, SMS, email, in-app)
- Quiet hours stored with a timezone (
tz), not a fixed UTC offset - Global unsubscribe: one switch that stops every non-critical message
- Legal opt-out: TCPA (SMS STOP) and CAN-SPAM (email unsubscribe) must be honored
2. Check Order and Failure
Priority Queues and Channel Workers
1. Priority Isolation
- Split traffic into 4 priorities: critical, high, normal, bulk
- Goal: a campaign never delays an OTP
- One Kafka topic per channel x priority, e.g.
sms.critical,push.bulk - Separate topics also stop a slow SMS provider from blocking push
| Priority | Example |
|---|
Retries, Dead Letters and Deduplication
1. What to Retry
| Failure | Class | Action |
|---|---|---|
| 5xx from provider | transient | retry |
| Timeout | transient | retry |
| 429 (rate limited) | transient | retry after the backoff delay |
Rate Limiting and Throttling
1. Three Limits, Three Jobs
| Limit | Example | Protects |
|---|---|---|
| Per-user | 5 marketing push/day, 3 SMS/hour | The user from spam |
| Critical abuse | 5 OTPs / 10 min per user | The SMS bill and the user from OTP floods |
| Per-provider | SES 14 emails/sec, Twilio 1 msg/sec per number |
Third-Party Providers and Failover
1. Adapter Interface
Provider layer of the Notification System: one adapter hides each vendor, so workers never see vendor APIs.
- Every adapter exposes send(msg) and returns
{provider_id, status} - One adapter each: FCM, APNs, Twilio, SES, SendGrid
- Adapter maps vendor errors to transient or permanent
- Swapping or adding a vendor touches only its adapter
class Adapter(Protocol): def send(self, msg: dict) -> dict: """Return {'provider_id': str, 'status': str}""" ...
same contract for FCM, APNs, Twilio, SES, SendGrid
Delivery Tracking and In-App Inbox
Running example: a Food Delivery App tracks an order-shipped notification from queued to clicked, and shows it in the inbox.
1. Status Lifecycle and Sources
- queuedaccepted
- sentto provider
- deliveredon device or inbox
- openeduser saw it
- clickedlink tapped
- Terminal states: failed, suppressed (pref or limit), bounced (bad address)
- Each status gets a rank: queued 1, sent 2, delivered 3, opened 4, clicked 5
Scaling, Failure Handling, Trade-offs and Interview Questions
1. Scale at 5k QPS Peak (Notification System)
| Layer | Sizing at 5k QPS peak | Rule |
|---|---|---|
| API pods | about 20 pods | stateless, add pods behind the LB |
| Channel workers | about 50 workers | autoscale on consumer lag |
| Kafka | 100 partitions per hot topic | key = user_id, keeps per-user order |
Check yourself
A producer POSTs a notification with idempotency_key K, times out, and retries with the same K. What stops the user getting two pushes, and what is the UNIQUE column on idempotency_key for?
- The Redis
SET NX EX 24hgate finds the existing key for the first request - The retry gets the original notification_id back with 202, and nothing new is enqueued
- The UNIQUE column is only a per-partition backstop: the table is partitioned by created_at, so a retry in another partition would not collide
- So the Redis gate is the dedup mechanism, not the UNIQUE index