Stage 4 · Provision
Design Case Studies
Design a Notification System
Fanout queues, user preferences, provider retries, deduplication, and delivery receipts.
Requirements
A notification system sends messages to users across multiple channels: push notifications, SMS, email, and in-app messages. It must handle user preferences, rate limiting, deduplication, and provider failover. The system must be reliable — no notification should be lost.
Functional:
- Multi-channel: push, SMS, email, in-app
- User preferences: opt-in/out per channel
- Templates: customizable notification content
- Scheduling: immediate and delayed notifications
- Deduplication: no duplicate notifications
Non-functional:
- Delivery: < 5 seconds for push, < 30s for SMS
- Throughput: 10M notifications/day
- Reliability: No notification loss (at-least-once)
- Durability: Notification history retained for 90 daysThe system must be reliable (no lost notifications), fast (sub-5-second push delivery), and flexible (multi-channel, user preferences).
Notification Channels
| Channel | Provider | Latency | Cost |
|---|---|---|---|
| Push (iOS) | APNs | 1-3 seconds | $0.0005/notification |
| Push (Android) | FCM | 1-3 seconds | Free |
| SMS | Twilio | 5-30 seconds | $0.0075/message |
| SES | 10-60 seconds | $0.10/1000 emails | |
| In-app | WebSocket | < 1 second | Infrastructure only |
Architecture
Producer (any service)
│
▼
Notification Queue (SQS/Kafka)
│
▼
Notification Worker
│
├── Fetch user preferences
├── Check deduplication
├── Apply rate limits
├── Select provider
│
├──► Push Worker ──► APNs / FCM
├──► SMS Worker ──► Twilio
├──► Email Worker ──► SES
└──► In-app Worker ──► WebSocket / SSE
│
▼
Delivery Tracker ──► Notification Log (database)The notification queue decouples producers from workers. Each channel has its own worker with provider-specific retry logic. The delivery tracker records delivery status.
Fanout Design
def send_notification(user_id: str, template: str, data: dict):
# Fetch user preferences
prefs = get_user_preferences(user_id)
# Determine which channels to use
channels = []
if prefs.get("push_enabled"):
channels.append("push")
if prefs.get("sms_enabled"):
channels.append("sms")
if prefs.get("email_enabled"):
channels.append("email")
# Fanout to each channel
for channel in channels:
message = {
"user_id": user_id,
"channel": channel,
"template": template,
"data": data,
"idempotency_key": generate_idempotency_key(user_id, template, data)
}
notification_queue.send(channel, message)Fanout to multiple channels based on user preferences. Each channel message is independent and can be retried separately.
Preference Management
User preferences determine which channels receive notifications. Store preferences in the database with per-channel opt-in/out. Cache preferences in Redis for fast lookup during fanout. Respect quiet hours and frequency caps.
Delivery Tracking
- Sent — Notification sent to provider (APNs, Twilio, SES).
- Delivered — Provider confirmed delivery to device/inbox.
- Opened — User opened the notification (push/email only).
- Failed — Provider returned an error (retry or dead-letter).
- Suppressed — Notification suppressed by rate limit or preference.
Use idempotency keys (user_id + template + data hash) to prevent duplicate notifications. If a worker crashes after sending but before recording delivery, the retry should not send again.
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.