Requirements & scope
Problem Statement & Requirements
Functional Requirements
- Support multiple notification channels: push (mobile), SMS, email, in-app (web/mobile badge + feed)
- Producers (any internal service) can trigger notifications via an API
- Template-based content with variable substitution (e.g., "{{user}} liked your photo")
- User notification preferences — per-channel opt-in/out, quiet hours, frequency caps
- Notification feed — paginated list of past notifications (in-app inbox)
- Delivery tracking — sent, delivered, opened/clicked, failed
- Priority levels — critical (2FA codes, payment alerts) vs. marketing (promos)
- Rate limiting — prevent notification fatigue (max N notifications per user per hour)
- Scheduling — send at a future time, timezone-aware
- Batching / digest — group related notifications ("3 people liked your photo" instead of 3 separate)
Non-Functional Requirements
- High throughput — 1B+ notifications/day
- Low latency for critical — 2FA codes delivered within 5 seconds
- At-least-once delivery — no notification silently dropped
- Exactly-once display — dedup on the client side
- Scalability — handle viral events (celebrity post → millions of notifications in seconds)
- Observability — per-channel delivery rate, failure rate, open rate
Out of Scope
- Content generation (the calling service provides the content)
- User-facing notification creation (this is an infrastructure service)
- Rich media rendering (handled by client apps)
Scale estimations
Scale Estimations
Traffic
| Metric | Value |
|---|---|
| Total notifications / day | 1B |
| Notifications / second (avg) | ~11,600 |
| Peak notifications / second | ~100K (viral event, breaking news) |
| Push notifications / day | 500M (50%) |
| Email notifications / day | 300M (30%) |
| SMS notifications / day | 50M (5%) |
| In-app notifications / day | 150M (15%) |
Fanout Scenarios
| Scenario | Recipients | Latency Target |
|---|---|---|
| 1:1 (payment receipt) | 1 | < 5s |
| Small group (team mention) | 10-50 | < 10s |
| Large group (channel update) | 1K-10K | < 30s |
| Broadcast (app-wide alert) | 10M-100M | < 5 min |
| Viral fanout (celebrity post) | 50M+ | < 10 min |
Storage
| Metric | Value |
|---|---|
| Notification record size | ~500 bytes |
| Notification feed storage / day | 1B × 500B = ~500 GB/day |
| Retention (90 days) | ~45 TB |
| User preferences | 500M users × 200 bytes = ~100 GB |
Third-Party Costs
| Channel | Provider | Rate Limit |
|---|---|---|
| iOS Push | APNs | ~Unlimited (but throttled per device token) |
| Android Push | FCM | ~Unlimited (batch up to 500 tokens/request) |
| SES / SendGrid | 50K-100K emails/sec | |
| SMS | Twilio / SNS | 1K-10K SMS/sec (varies by country) |
Layered architecture
Phase 1 of 4 · Client & edge
Start with the entry points
First, define who uses the system and where requests enter. This is the boundary of the design, not yet a complete request path.
- Producers
- Accept notification intent independently of any particular email, SMS, or push provider.
- Notification API
- Require a stable deduplication key and validate the recipient and template variables.
This first layer defines the system boundary. The next chapters add application logic and data dependencies. After examining the request paths and tradeoffs, we will add operational safeguards and bring the complete architecture together.
API & contracts
Phase 2 of 4 · Application & compute
Add the application logic
Keep the entry points and add the services that do the work. Give each service a clear responsibility before defining its contracts. Storage and background dependencies come next.
- Policy engine
- Apply opt-outs, timezone-aware quiet hours, and channel-specific rate limits before delivery.
- Channel workers
- Render channel-specific content and isolate provider integrations behind adapters.
API Design
Send Notification
POST /api/v1/notifications
Content-Type: application/json
X-Idempotency-Key: "order-123-shipped"
Request:
{
"recipients": {
"user_ids": ["u123", "u456"], // explicit user list
// OR
"segment": "premium_users", // audience segment
// OR
"topic": "breaking_news" // pub/sub topic
},
"template_id": "order_shipped",
"template_data": {
"order_id": "ORD-789",
"item_name": "Wireless Headphones",
"tracking_url": "https://track.example.com/xyz"
},
"channels": ["push", "email"], // override user prefs (optional)
"priority": "high", // critical | high | normal | low
"schedule_at": "2026-04-04T09:00:00Z", // future send (optional)
"ttl": 3600, // expire if not delivered in 1h
"collapse_key": "order_update_789", // replace previous with same key
"metadata": {
"source": "order-service",
"campaign_id": "spring_sale_2026"
}
}
Response (202 Accepted):
{
"notification_id": "n_abc123",
"status": "queued",
"estimated_recipients": 2,
"channels": ["push", "email"]
}Query Notification Feed (In-App)
GET /api/v1/users/{user_id}/notifications?cursor=abc&limit=20
Response:
{
"notifications": [
{
"id": "n_abc123",
"type": "order_shipped",
"title": "Your order has shipped!",
"body": "Wireless Headphones is on the way.",
"image_url": "https://...",
"action_url": "https://track.example.com/xyz",
"read": false,
"created_at": "2026-04-03T14:30:00Z"
},
...
],
"next_cursor": "def",
"unread_count": 7
}User Preferences
PUT /api/v1/users/{user_id}/notification-preferences
{
"channels": {
"push": { "enabled": true },
"email": { "enabled": true, "digest": "daily" },
"sms": { "enabled": false }
},
"quiet_hours": {
"enabled": true,
"start": "22:00",
"end": "08:00",
"timezone": "America/New_York"
},
"categories": {
"marketing": { "enabled": false },
"social": { "enabled": true, "channels": ["push"] },
"transactional": { "enabled": true, "channels": ["push", "email", "sms"] }
}
}Data model
Phase 3 of 4 · Data & async work
Connect data and background work
The services now need their dependencies: durable state, fast lookups, or work that continues outside a request. Separate these responsibilities rather than treating every component as a database.
- Priority queues
- Separate urgent transactional traffic from bulk traffic so one cannot starve the other.
- Delivery store
- Persist attempts and provider receipts; accepted by a provider does not mean delivered to a person.
- Providers
- Handle provider throttling, timeout ambiguity, and channel-specific delivery semantics.
Data Model
Notification Log (Cassandra)
Delivery Status (ClickHouse — Analytics)
Core design decisions
Fanout Strategies
The Fanout Problem
Strategy 1: Fanout-on-Write (Push Model)
Strategy 2: Fanout-on-Read (Pull Model)
Strategy 3: Hybrid (Recommended)
Request flows
Notification Processing Pipeline
Full Flow Diagram
Priority Routing
Performance & caching
Notification Deduplication & Idempotency
Advanced design
Push Notification Deep Dive
Push Architecture (iOS + Android + Web)
APNs (Apple Push Notification Service) Integration
FCM (Firebase Cloud Messaging) Integration
Notification Batching & Digests
Rate Limiting & Quiet Hours
Delivery Tracking & Analytics
Template Engine
Scheduled & Timezone-Aware Notifications
Edge cases
A user disables promotional email after a campaign has already entered the queue. Check the relevant preference again before delivery rather than relying only on the snapshot taken when the job was created. Provider timeouts need similar care: retain the provider’s operation ID and distinguish an unknown outcome from a confirmed rejection.
Tradeoffs
Tradeoffs & Design Decisions Summary
| Decision | Option A | Option B | Chosen | Why |
|---|---|---|---|---|
| Fanout | Fanout-on-write | Fanout-on-read | Hybrid | Write for small audiences + push; read/background for VIP/broadcast |
| Queue | Redis Pub/Sub | Kafka | Kafka | Durability, replay, consumer groups, ordering, DLQ support |
| Priority | Single queue + priority field | Separate topics per priority | Separate topics | No head-of-line blocking; critical path fully isolated |
| Notification DB | MySQL | Cassandra | Cassandra | Write-heavy (1B/day), partition by user, TTL for auto-cleanup |
| Analytics DB | PostgreSQL | ClickHouse | ClickHouse | Columnar, fast aggregations over billions of delivery events |
| Template storage | Database only | Redis cache + DB | Redis + MySQL | Templates are read-heavy, small, and rarely change |
| Scheduling | DB polling | Redis sorted set | Redis sorted set | O(log N) operations, no polling waste, low latency |
| Retry | Immediate retry loop | Exponential backoff + DLQ | Backoff + DLQ | Prevents hammering failed providers; DLQ for investigation |
| Rate limiting | Per-service | Per-user per-channel | Per-user per-channel | Prevents notification fatigue regardless of source |
| Idempotency | None | Redis SETNX with TTL | Redis SETNX | Producers may retry; prevents duplicate notifications |
| Push delivery | Individual sends | Batch API (FCM 500/req) | Batch where possible | 10x fewer API calls to FCM; APNs uses HTTP/2 multiplexing |
| Quiet hours | Drop notifications | Defer to next allowed window | Defer | Don't lose notifications; deliver at appropriate time |
Reliability & fault tolerance
Phase 4 of 4 · Operations & resilience
Add operational safeguards
With the request paths and tradeoffs established, add the controls that help detect trouble and keep the system operating. The final diagram retains every layer introduced so far.
- Retry + DLQ
- Retry transient failures with backoff and jitter; quarantine persistent failures for controlled replay.
Retry & Dead Letter Queue
Production architecture
High-Level Architecture
Detailed Component Architecture
Production Architecture
Further exploration
Send an urgent security alert while a large newsletter campaign is draining. Trace where the urgent work might still wait: queue partitions, worker capacity, and provider quotas. Separate priority queues are useful only if the downstream resources can also reserve capacity for urgent traffic.
Interview playbook
Interview Tips
Start by clarifying channels — "Which channels do we need? Push, email, SMS, in-app? All of them?" This scopes the design.
Draw the pipeline first — Producer → API → Kafka → Workers → Providers. This is the backbone.
Separate critical from non-critical immediately — "2FA codes and marketing promos must not share the same queue." This shows operational maturity.
Discuss fanout for broadcast — "What happens when a celebrity posts and 50M users need a notification?" Show chunked fanout, FCM topics, and background workers.
User preferences are a first-class concern — Don't treat them as an afterthought. "Users must be able to opt out per channel, set quiet hours, and control frequency."
Rate limiting prevents fatigue — "Without rate limiting, a burst of social activity could send 50 push notifications in a minute." Show per-user per-channel limits.
Idempotency is critical — "The producer might retry. Kafka gives at-least-once. We need dedup at every layer." Show the idempotency key pattern.
Don't forget device token management — "Tokens go stale when users uninstall the app. APNs returns 410 → we must clean up."
Batching shows design depth — "3 people liked your photo" instead of 3 separate notifications. Explain time-window and count-threshold strategies.
End with observability — "How do we know it's working? Delivery rate dashboards, p99 latency, DLQ depth alerts." This is what separates a design from a production system.