Design a Notification System: Build One That Won't Collapse at 3 AM
Design a notification system that handles millions of messages without falling over.
20+ years shipping large-scale distributed systems. Notes here come from systems that actually shipped.
- ✓Basic understanding of web services (REST APIs, HTTP)
- ✓Familiarity with queues (e.g., RabbitMQ, Kafka) at a conceptual level
- ✓Some experience with databases (SQL or NoSQL)
Design a notification system by separating concerns: ingestion (accept notifications), processing (format, route), delivery (send via channels), and storage (log for audit). Use a queue to buffer spikes, a database for state, and idempotency keys to prevent duplicates.
Think of a notification system like a post office. You drop a letter (notification) in a mailbox (API). The post office sorts it (processing), puts it on the right truck (queue), and delivers it (email/SMS/push). If a truck breaks down, they retry later. Without the post office, you'd have to hand-deliver every letter yourself — and if you got 10,000 letters at once, you'd be buried.
Ever been woken up at 3 AM because your notification system sent 50,000 duplicate emails to users? I have. The root cause? A naive design that treated notifications as synchronous HTTP calls. Here's the blunt truth: most notification systems fail because they ignore the hard parts — reliability, ordering, and idempotency. This article walks you through building one that survives production. By the end, you'll be able to design a system that handles millions of notifications per day, retries failures gracefully, and doesn't lose a single message.
Why Notifications Need Their Own System
You could send emails directly from your web server. That works until your server blocks on a slow SMTP call and your whole app freezes. Or until a marketing campaign triggers 100K emails and your database connection pool exhausts. A notification system decouples sending from your main app. It's a buffer that absorbs spikes, retries failures, and lets your app respond fast. Without it, you're one viral tweet away from a production outage.
Core Components: The Post Office Analogy
A notification system has four parts: Ingestion (the mailbox), Processing (the sorting office), Delivery (the trucks), and Storage (the records). Ingestion is a simple API that accepts a notification request and writes it to a queue. Processing reads from the queue, formats the message (HTML email, SMS text, push payload), and routes it to the right channel. Delivery actually sends it via a provider (SendGrid, Twilio, Firebase). Storage logs every notification for audit and debugging.
Ingestion API: Accept Notifications Without Breaking
The ingestion API is a simple POST endpoint. It validates the request, assigns a unique idempotency key (if not provided), and writes to a queue. Never write to the database synchronously here — that's the worker's job. The API should return 202 Accepted immediately. This keeps your web server responsive. The classic mistake: blocking on a database write or a queue publish that fails. Use async writes with retries.
Processing Worker: Format and Route
The worker pulls messages from the queue, formats them for each channel, and sends them to the delivery layer. This is where you handle templates, localization, and channel selection. Keep workers stateless — all state lives in the message and a database. If a worker crashes, another picks up the message (thanks to queue visibility timeout). The gotcha: workers must handle poison pills — messages that always fail. Move them to a dead-letter queue after 3 retries.
Delivery Layer: Talk to Providers Safely
Delivery is the most failure-prone part. Email providers rate-limit you. SMS gateways time out. Push notification services have outages. The delivery layer must handle retries with exponential backoff and jitter. Never retry immediately — you'll hammer the provider and make things worse. Also, track delivery status per provider: success, failed, bounced, etc. This data is gold for debugging.
Storage: Log Everything for Debugging
Every notification attempt should be logged: user_id, channel, idempotency_key, status, timestamp, error message. This is your audit trail. Use a separate database or a time-series store (like Elasticsearch) for logs. Don't use your main transactional DB — notification logs are write-heavy and can cause contention. The gotcha: log writes must be async and non-blocking. If logging fails, the notification should still be delivered.
Scaling: Handle Millions of Notifications
To scale, you need multiple workers consuming from the same queue. Use auto-scaling based on queue depth. Each worker should be stateless — no local state that ties it to a specific message. For ordering, use a FIFO queue with a message group ID (e.g., per user). For high throughput, use a standard queue (no ordering) and accept that messages may be delivered out of order. The trade-off: ordering vs. throughput.
Idempotency: Stop Duplicate Notifications
Idempotency is the single most important feature. Without it, a retry sends the same notification twice. The fix: assign a unique idempotency key to each notification (e.g., UUID). Before sending, check if that key has already been processed. Store processed keys in a database with a TTL (e.g., 7 days). Use a unique constraint on the key to prevent race conditions. The classic mistake: checking idempotency in application code without a database constraint — two concurrent requests can both pass the check.
Monitoring: Know When It Breaks
You need metrics: queue depth, delivery latency, success rate per channel, error rate per provider. Alert on queue depth > 10K (backlog growing) and success rate < 95%. Also monitor the dead-letter queue — if it's growing, something is systematically failing. The gotcha: don't alert on every single failure. Some failures are transient. Alert on sustained failure rates over a time window (e.g., 5 minutes).
The 4GB Container That Kept Dying
- Never load an entire queue into memory.
- Always process in bounded batches with backpressure.
aws sqs get-queue-attributes --queue-url <url> --attribute-names ApproximateNumberOfMessages). 2. Check worker logs for errors. 3. Verify worker is running and consuming messages. 4. Check DLQ for failed messages.kubectl get pods -l app=notification-workerkubectl logs -l app=notification-worker --tail=50kubectl scale deployment notification-worker --replicas=10| File | Command / Code | Purpose |
|---|---|---|
| ingestion_api.py | from flask import Flask, request, jsonify | Ingestion API |
| worker.py | from time import sleep | Processing Worker |
| delivery.py | def send_email_with_retry(to, subject, body, max_retries=3): | Delivery Layer |
| idempotency.sql | CREATE TABLE notification_idempotency ( | Idempotency |
Key takeaways
Interview Questions on This Topic
How does a notification system handle duplicate deliveries under concurrent load?
Frequently Asked Questions
20+ years shipping large-scale distributed systems. Notes here come from systems that actually shipped.
That's Real World. Mark it forged?
3 min read · try the examples if you haven't