Chapter 59: Design Notification System
1. Requirements
Functional Requirements
| Requirement |
Details |
Constraints |
| Multi-Channel Support |
Push notifications, SMS, Email, In-app messages |
Channel-specific formatting, provider integration |
| Priority Levels |
P0 (critical), P1 (transactional), P2 (marketing) |
P0 bypasses rate limits, guaranteed delivery |
| User Preferences |
Opt-in/out per channel, quiet hours, frequency caps |
Real-time preference checks before sending |
| Deduplication |
Prevent duplicate notifications within time window |
Idempotency keys, sliding window tracking |
| Template Management |
Parameterized templates, localization support |
Channel-specific formatting constraints |
| Delivery Tracking |
Track sent/delivered/read/failed/bounced states |
Provider webhooks, retry logic, DLQ |
Non-Functional Requirements
| Aspect |
Target |
Strategy |
| Scale |
100M notifications/day (~1,157 req/sec avg, 5K peak) |
Horizontal scaling, queue-based architecture |
| Latency |
P0: <1s, P1: <5s, P2: <1min |
Priority queues, separate worker pools |
| Reliability |
99.9% delivery for P0/P1, at-least-once semantics |
Retries, DLQ, provider failover |
| Availability |
99.95% uptime |
Multi-region, stateless workers, managed queues |
| Throughput |
Burst to 10K req/sec during campaigns |
Auto-scaling workers, batch processing |
Capacity Estimates
| Metric |
Calculation |
Result |
| Daily Volume |
100M notifications/day |
1,157 req/sec avg |
| Peak Load |
5x average (marketing campaigns) |
5,785 req/sec |
| Storage (30 days) |
100M/day Γ 30 days Γ 2KB avg |
6TB (compressed ~2TB) |
| Queue Size |
5K req/sec Γ 60 sec buffer |
300K messages in-flight |
| Workers (P1) |
5K req/sec Γ· 100 req/sec/worker |
50-100 workers per priority level |
2. High-Level Design
graph TB
subgraph Producers
A1[Order Service]
A2[Auth Service]
A3[Marketing Service]
A4[Alerting Service]
end
subgraph "Notification API Layer"
B[Notification API]
B2[Rate Limiter]
B3[Validator]
end
subgraph "Queue Layer (Kafka/SQS)"
C1[P0 Queue
Critical]
C2[P1 Queue
Transactional]
C3[P2 Queue
Marketing]
end
subgraph "Processing Layer"
D1[P0 Workers]
D2[P1 Workers]
D3[P2 Workers]
D4[Preference Service]
D5[Template Engine]
D6[Deduplication Cache]
end
subgraph "Channel Routers"
E1[Push Router]
E2[SMS Router]
E3[Email Router]
E4[In-App Router]
end
subgraph "External Providers"
F1[APNs/FCM]
F2[Twilio/SNS]
F3[SendGrid/SES]
F4[WebSocket Server]
end
subgraph "Storage Layer"
G1[(User Preferences
DynamoDB)]
G2[(Templates
S3/DB)]
G3[(Delivery Logs
Cassandra)]
G4[Dedup Cache
Redis]
end
A1 --> B
A2 --> B
A3 --> B
A4 --> B
B --> B2
B2 --> B3
B3 --> C1
B3 --> C2
B3 --> C3
C1 --> D1
C2 --> D2
C3 --> D3
D1 --> D4
D2 --> D4
D3 --> D4
D4 --> D6
D6 --> D5
D5 --> E1
D5 --> E2
D5 --> E3
D5 --> E4
E1 --> F1
E2 --> F2
E3 --> F3
E4 --> F4
D4 -.-> G1
D5 -.-> G2
E1 -.-> G3
E2 -.-> G3
E3 -.-> G3
E4 -.-> G3
D6 -.-> G4
F1 -.webhook.-> G3
F2 -.webhook.-> G3
F3 -.webhook.-> G3
Component Responsibilities
| Component |
Responsibility |
Technology |
| Notification API |
Accept notification requests, validate schema, route to priority queue |
Node.js/Go, REST/gRPC |
| Rate Limiter |
Enforce API rate limits per service, prevent abuse |
Redis (token bucket) |
| Priority Queues |
Separate queues for P0/P1/P2, FIFO ordering within priority |
Kafka (partitioned), SQS |
| Workers |
Poll queue, check preferences/dedup, render template, route to channel |
Kubernetes pods, auto-scaling |
| Preference Service |
Check user opt-in/out, quiet hours, frequency caps |
DynamoDB (user_id partition) |
| Template Engine |
Render parameterized templates with localization |
Handlebars/Jinja, cached in Redis |
| Deduplication Cache |
Track recent notifications by idempotency key + user + type |
Redis (TTL = dedup window) |
| Channel Routers |
Channel-specific logic, provider failover, batch optimization |
Stateless service per channel |
| External Providers |
APNs, FCM, Twilio, SendGrid, etc. |
3rd party APIs |
| Delivery Logs |
Store notification state transitions, enable analytics |
Cassandra (append-only) |
3. Notification Flow
sequenceDiagram
participant S as Service
participant API as Notification API
participant RL as Rate Limiter
participant Q as Priority Queue
participant W as Worker
participant PS as Preference Service
participant DC as Dedup Cache
participant TE as Template Engine
participant CR as Channel Router
participant P as Provider (APNs/Twilio/SES)
participant DL as Delivery Log
S->>API: POST /notifications
{user_id, type, priority, template_id, params}
API->>RL: Check rate limit (service_id)
RL-->>API: OK
API->>API: Validate schema, assign idempotency_key
API->>Q: Enqueue to P0/P1/P2 queue
Q-->>API: Queued
API-->>S: 202 Accepted {notification_id}
Q->>W: Poll message (partition by user_id)
W->>PS: Get user preferences (user_id)
PS-->>W: {email: opt-in, sms: opt-out, quiet_hours: 22-08}
alt During quiet hours (P2 only)
W->>Q: Re-queue with delay
end
alt Channel opted out
W->>DL: Log SKIPPED (user preference)
W->>Q: Ack message
end
W->>DC: Check dedup (user_id + type + idempotency_key)
DC-->>W: Not seen (OK to send)
W->>DC: Set dedup entry (TTL 15min)
W->>TE: Render template (template_id, params, locale, channel)
TE-->>W: Rendered content
W->>CR: Route to channel (email/sms/push/in-app)
CR->>P: Send via provider
P-->>CR: 200 OK {provider_msg_id}
CR->>DL: Log SENT
CR-->>W: Success
W->>Q: Ack message
P-->>DL: Webhook: DELIVERED/READ/FAILED
DL->>DL: Update notification state
alt Delivery failed
DL->>Q: Re-queue for retry (exponential backoff)
end
Flow Steps Detailed
| Step |
Action |
Error Handling |
| 1. API Request |
Service sends notification request with user_id, type, priority, template_id, params |
400 if validation fails, 429 if rate limited, 503 if queue full |
| 2. Rate Limiting |
Check service-level rate limit (e.g., 1000 req/min per service) |
Return 429 Too Many Requests, client backs off |
| 3. Queue Routing |
Route to P0/P1/P2 queue based on priority, partition by user_id for ordering |
If queue full, reject with 503 (P2) or retry (P0/P1) |
| 4. Preference Check |
Fetch user preferences from DynamoDB, check channel opt-in/out, quiet hours |
If opted out, log SKIPPED and ack. If DB error, fail-open for P0 |
| 5. Deduplication |
Check Redis for (user_id + type + idempotency_key) within sliding window (15min) |
If duplicate, log DEDUPLICATED and ack. If cache miss, fail-open |
| 6. Template Rendering |
Load template from cache/DB, substitute params, apply localization, format for channel |
If template missing, use fallback. If render fails, log and DLQ |
| 7. Channel Routing |
Route to push/SMS/email/in-app router, apply channel-specific logic |
Provider failover (primary β secondary), retry with backoff |
| 8. Provider Delivery |
Send to APNs/FCM/Twilio/SendGrid, get provider message ID |
Retry 3x with exp backoff (1s, 2s, 4s), then DLQ |
| 9. Delivery Tracking |
Log SENT state, listen for provider webhooks (DELIVERED/READ/FAILED/BOUNCED) |
Retry on transient failures, alert on bounce rate spike |
Message Schema (Queue)
| Field |
Type |
Description |
| notification_id |
UUID |
Unique notification identifier |
| idempotency_key |
String |
Deduplication key (from client or auto-generated) |
| user_id |
String |
Recipient user ID (partition key) |
| priority |
Enum |
P0 (critical), P1 (transactional), P2 (marketing) |
| type |
String |
Notification type (e.g., "order_shipped", "otp", "promo") |
| channels |
Array |
Target channels: ["push", "sms", "email", "in_app"] |
| template_id |
String |
Template identifier |
| params |
Map |
Template parameters (e.g., {order_id: "123", total: "$50"}) |
| locale |
String |
User locale (en-US, es-MX, etc.) |
| metadata |
Map |
Service context, tracking IDs |
| created_at |
Timestamp |
Enqueue time (for latency monitoring) |
| retry_count |
Int |
Current retry attempt (0 = first try) |
4. Priority & Rate Limiting
Priority Levels
| Priority |
Use Cases |
SLA |
Rate Limits |
Retry Policy |
| P0 (Critical) |
OTP, security alerts, fraud detection, account lockout |
<1s latency, 99.99% delivery |
No user rate limit, bypasses quiet hours |
Retry 5x, exp backoff (1s β 16s), dedicated DLQ |
| P1 (Transactional) |
Order confirmation, shipment tracking, payment receipt |
<5s latency, 99.9% delivery |
100/hour per user per channel |
Retry 3x, exp backoff (2s β 8s), DLQ after 3 failures |
| P2 (Marketing) |
Promotions, newsletters, feature announcements |
<1min latency, 95% delivery, best effort |
10/hour per user per channel, respects quiet hours |
Retry 2x, exp backoff (5s β 20s), drop after 2 failures |
Rate Limiting Strategy
| Level |
Scope |
Limit |
Algorithm |
| API Rate Limit |
Per service (producer) |
1000 req/min |
Token bucket (Redis) |
| User Rate Limit (P1/P2) |
Per user per channel |
P1: 100/hour, P2: 10/hour |
Sliding window counter (Redis) |
| Provider Rate Limit |
Per provider API |
APNs: 10K/sec, Twilio: 1K/sec per account |
Token bucket per provider |
| Global Rate Limit |
System-wide (circuit breaker) |
10K req/sec (hard cap) |
Reject with 503 if exceeded |
Priority Queue Implementation (Kafka)
| Topic |
Partitions |
Retention |
Consumer Group |
| notifications-p0 |
16 (partition by user_id hash) |
7 days |
p0-workers (100 consumers) |
| notifications-p1 |
32 (partition by user_id hash) |
7 days |
p1-workers (200 consumers) |
| notifications-p2 |
64 (partition by user_id hash) |
3 days |
p2-workers (100 consumers, lower priority pods) |
| notifications-dlq |
8 |
30 days |
Manual review / replay job |
Rate Limiting Pseudocode (Redis Token Bucket)
def check_user_rate_limit(user_id, channel, priority):
if priority == "P0":
return True # P0 bypasses user rate limits
key = f"rate_limit:{user_id}:{channel}:{priority}"
limit = 100 if priority == "P1" else 10 # per hour
window = 3600 # 1 hour in seconds
current = redis.get(key) or 0
if current >= limit:
return False # Rate limited
redis.incr(key)
redis.expire(key, window)
return True
5. Channel Routing
Channel Router Specifications
| Channel |
Providers |
Format Constraints |
Failover Strategy |
| Push (Mobile) |
Primary: FCM (Android), APNs (iOS) Secondary: OneSignal |
Payload: 4KB max, title: 65 chars, body: 240 chars |
If APNs 503, retry 3x then switch to OneSignal. Track device tokens per provider. |
| SMS |
Primary: Twilio Secondary: AWS SNS |
160 chars (GSM-7), 70 chars (Unicode). Auto-segment long messages. |
If Twilio timeout, failover to SNS. Round-robin across accounts if rate limited. |
| Email |
Primary: SendGrid Secondary: AWS SES |
HTML + plain text, subject: 78 chars, attachments: 10MB total |
If SendGrid bounce rate >5%, route new emails to SES. Per-provider reputation tracking. |
| In-App |
WebSocket server (internal), Pusher (backup) |
JSON payload, real-time delivery if user online, else persist for 30 days |
If WS connection unavailable, store in inbox table (DynamoDB), poll on app open. |
Push Notification Flow (APNs/FCM)
| Step |
APNs (iOS) |
FCM (Android) |
| Device Token |
64-hex string, expires on app reinstall |
152-char token, refreshes periodically |
| Authentication |
JWT with .p8 key (team_id, key_id) |
OAuth2 with service account JSON |
| Endpoint |
https://api.push.apple.com/3/device/{token} |
https://fcm.googleapis.com/v1/projects/{project_id}/messages:send |
| Payload |
{"aps": {"alert": {"title": "...", "body": "..."}, "badge": 1}} |
{"message": {"notification": {"title": "...", "body": "..."}, "data": {...}}} |
| Priority |
apns-priority: 10 (immediate), 5 (defer) |
android.priority: HIGH, NORMAL |
| Error Codes |
400 BadDeviceToken, 410 Unregistered, 429 TooManyRequests |
404 NOT_FOUND, 429 QUOTA_EXCEEDED, 503 UNAVAILABLE |
| Retry Logic |
410 β remove token, 429 β backoff 1min, 503 β retry 3x |
404 β remove token, 429 β backoff 1min, 503 β retry 3x |
SMS Router Logic
| Aspect |
Implementation |
Details |
| Provider Selection |
Round-robin across Twilio accounts + SNS backup |
Track per-account rate limits (1K msg/sec per Twilio account) |
| Phone Validation |
E.164 format (+1234567890), verify via Twilio Lookup API |
Cache valid numbers in Redis (TTL 30 days) |
| Message Segmentation |
Split messages >160 chars into segments (GSM-7) or 70 chars (Unicode) |
Track segment count for cost estimation |
| Link Shortening |
Shorten URLs to save chars (Bitly API or internal service) |
Track click-through for marketing SMS |
| Delivery Receipts |
Twilio webhook β update delivery status (sent/delivered/failed/undelivered) |
Store in Cassandra with (notification_id, segment_id) |
| Opt-Out Handling |
Parse "STOP" replies, update user preference to opt-out |
Legal requirement (TCPA compliance) |
Email Router Logic
| Feature |
Implementation |
Provider |
| HTML Rendering |
Inline CSS, mobile-responsive templates, dark mode support |
Template engine + Juice (CSS inliner) |
| Plain Text Fallback |
Generate from HTML via html2text, always include for deliverability |
Required by RFC 2046 (multipart/alternative) |
| Personalization |
Merge tags: {{first_name}}, {{order_id}}, per-user content |
Template engine substitution |
| Attachments |
Fetch from S3, attach to email (receipts, invoices) |
SendGrid/SES attachment API |
| Tracking |
Open tracking (1px image), click tracking (rewrite links) |
SendGrid Event Webhook |
| Bounce Handling |
Hard bounce β remove email, soft bounce β retry 3x then suppress |
SendGrid/SES webhook β update suppression list |
| SPF/DKIM/DMARC |
Configure DNS records for domain authentication |
Prevents spoofing, improves deliverability |
Provider Failover Decision Tree
def send_notification(channel, payload):
providers = get_providers(channel) # [primary, secondary]
for provider in providers:
if is_rate_limited(provider):
continue # Try next provider
try:
response = provider.send(payload)
if response.status == 200:
log_delivery(provider, "SENT", response.msg_id)
return SUCCESS
elif response.status in [429, 503]: # Transient error
log_delivery(provider, "RETRY", response.msg_id)
continue # Failover to next provider
else: # 4xx error (bad request, invalid token)
log_delivery(provider, "FAILED", response.error)
return FAILURE
except TimeoutException:
log_delivery(provider, "TIMEOUT", None)
continue # Try next provider
return FAILURE # All providers exhausted
6. Template Engine
Template Structure
| Field |
Description |
Example |
| template_id |
Unique template identifier |
"order_shipped_v2" |
| channel |
Target channel (push/sms/email/in_app) |
"email" |
| locale |
Language/region |
"en-US", "es-MX", "fr-CA" |
| subject |
Email subject or push title |
"Your order {{order_id}} has shipped!" |
| body |
Message body (plain text or HTML) |
"Track your package: {{tracking_url}}" |
| variables |
Required parameters and types |
{"order_id": "string", "tracking_url": "url"} |
| fallback |
Default values if param missing |
{"tracking_url": "https://example.com/track"} |
| version |
Template version for A/B testing |
2 |
Localization Strategy
| Aspect |
Approach |
Details |
| Template Storage |
One template per (template_id, locale, channel) |
S3: templates/{template_id}/{locale}/{channel}.json |
| Locale Fallback |
es-MX β es β en (default) |
If specific locale missing, try language, then default |
| String Translation |
Professional translation service (e.g., Phrase, Lokalise) |
Export templates β translate β import |
| Number Formatting |
Use locale-aware formatters |
$1,234.56 (en-US), 1 234,56 $ (fr-CA) |
| Date/Time |
Format per locale (12h vs 24h, DD/MM vs MM/DD) |
Store as ISO8601, format at render time |
| Timezone |
Convert to user's timezone (from profile) |
UTC in DB β user TZ in notification |
Channel-Specific Formatting
| Channel |
Constraints |
Formatting Rules |
| Push |
Title: 65 chars, Body: 240 chars, Payload: 4KB |
Truncate with ellipsis, no HTML, Unicode emoji supported |
| SMS |
160 chars (GSM-7), 70 chars (Unicode), segments cost extra |
Shorten URLs, avoid emoji (use Unicode sparingly), plaintext only |
| Email |
Subject: 78 chars, Body: unlimited (HTML + plain), Attachments: 10MB |
Inline CSS, responsive design, alt text for images, unsubscribe link required |
| In-App |
Flexible JSON payload, rich content (images, actions) |
Markdown for formatting, support deep links, action buttons |
Template Rendering Pseudocode
def render_template(template_id, params, locale, channel):
# 1. Load template with locale fallback
template = load_template(template_id, locale, channel)
if not template:
template = load_template(template_id, "en", channel) # Fallback
# 2. Validate required parameters
for var in template.required_variables:
if var not in params:
params[var] = template.fallback.get(var, "")
# 3. Render subject and body
subject = render_string(template.subject, params, locale)
body = render_string(template.body, params, locale)
# 4. Apply channel-specific formatting
if channel == "push":
subject = truncate(subject, 65)
body = truncate(body, 240)
elif channel == "sms":
body = strip_html(body)
body = shorten_urls(body)
if len(body) > 160:
body = segment_sms(body) # Split into parts
elif channel == "email":
body = inline_css(body) # For email clients
body = add_tracking_pixels(body)
body += render_unsubscribe_link(params.user_id)
return {
"subject": subject,
"body": body,
"metadata": template.metadata
}
Template Caching Strategy
| Cache Layer |
Technology |
TTL |
| L1 (Worker Memory) |
In-process LRU cache |
5 minutes (invalidate on deploy) |
| L2 (Distributed) |
Redis |
1 hour (invalidate on template update) |
| L3 (Origin) |
S3 + DB |
Permanent (versioned) |
7. User Preferences
Preference Schema (DynamoDB)
| Field |
Type |
Description |
| user_id |
String (PK) |
User identifier (partition key) |
| channel_preferences |
Map |
{"push": {...}, "sms": {...}, "email": {...}, "in_app": {...}} |
| push.enabled |
Boolean |
Global push opt-in (default: true) |
| push.categories |
Map |
{"marketing": false, "transactional": true, "critical": true} |
| push.device_tokens |
List |
[{token: "...", platform: "ios", last_active: "2026-05-01"}] |
| sms.enabled |
Boolean |
Global SMS opt-in (default: false, requires explicit consent) |
| sms.phone_number |
String |
E.164 format (+1234567890) |
| email.enabled |
Boolean |
Global email opt-in (default: true) |
| email.address |
String |
User email address (verified) |
| email.categories |
Map |
{"marketing": true, "transactional": true, "critical": true} |
| quiet_hours |
Map |
{"enabled": true, "start": "22:00", "end": "08:00", "timezone": "America/Los_Angeles"} |
| frequency_cap |
Map |
{"push": 50, "sms": 5, "email": 20} (per day, P2 only) |
| locale |
String |
Preferred language (en-US, es-MX, etc.) |
| updated_at |
Timestamp |
Last preference update |
Preference Check Logic
| Check |
Logic |
Applies To |
| Channel Opt-In |
If channel.enabled == false, skip notification |
P1, P2 (P0 bypasses for critical alerts) |
| Category Opt-In |
If channel.categories[type] == false, skip |
P1, P2 (P0 bypasses) |
| Quiet Hours |
If current time in (start, end) and quiet_hours.enabled, defer to end time |
P2 only (P0/P1 bypass) |
| Frequency Cap |
If user exceeded cap today, drop notification |
P2 only (P0/P1 bypass) |
| Suppression List |
If email/phone on bounce/complaint list, skip |
All priorities (legal compliance) |
Preference Management API
| Endpoint |
Method |
Purpose |
| /preferences/{user_id} |
GET |
Fetch current preferences |
| /preferences/{user_id} |
PUT |
Update preferences (merge) |
| /preferences/{user_id}/channel/{channel} |
PATCH |
Update specific channel preferences |
| /preferences/{user_id}/unsubscribe |
POST |
One-click unsubscribe (email footer link) |
| /preferences/{user_id}/device-token |
POST |
Register/update push device token |
Quiet Hours Implementation
def is_quiet_hours(user_prefs, current_time):
if not user_prefs.quiet_hours.enabled:
return False
# Convert current_time to user's timezone
user_tz = pytz.timezone(user_prefs.quiet_hours.timezone)
local_time = current_time.astimezone(user_tz)
start = parse_time(user_prefs.quiet_hours.start) # "22:00" β 22
end = parse_time(user_prefs.quiet_hours.end) # "08:00" β 8
current_hour = local_time.hour
if start < end: # e.g., 08:00 - 18:00
return current_hour >= start and current_hour < end
else: # Wraps midnight, e.g., 22:00 - 08:00
return current_hour >= start or current_hour < end
Frequency Cap Tracking (Redis)
def check_frequency_cap(user_id, channel, priority):
if priority in ["P0", "P1"]:
return True # No cap for high priority
today = date.today().isoformat() # "2026-05-12"
key = f"freq_cap:{user_id}:{channel}:{today}"
count = redis.get(key) or 0
cap = get_user_preference(user_id).frequency_cap[channel]
if count >= cap:
return False # Cap exceeded
redis.incr(key)
redis.expire(key, 86400) # Expire at end of day
return True
8. Deduplication
Deduplication Strategy
| Aspect |
Implementation |
Details |
| Dedup Key |
SHA256(user_id + type + idempotency_key) |
Client provides idempotency_key or auto-generated from message content |
| Sliding Window |
15 minutes (configurable per notification type) |
Prevent re-sending same notification within window |
| Storage |
Redis SET with TTL = window duration |
Key: dedup:{user_id}:{type}:{hash}, Value: notification_id |
| Cache Miss |
Fail-open (allow notification) |
Prefer duplicate over dropped notification for P0/P1 |
| Multi-Channel |
Deduplicate per channel or global (configurable) |
Same OTP via SMS + email = OK, duplicate push = skip |
Idempotency Key Sources
| Source |
Example |
Use Case |
| Client-Provided |
order_id + event_type ("order_123_shipped") |
Transactional notifications (one per event) |
| Auto-Generated (Content Hash) |
SHA256(user_id + template_id + params) |
Marketing notifications (prevent duplicate promos) |
| Request ID |
UUID from API request header |
Retry safety (client can retry with same request_id) |
| None (Always Send) |
OTP, security alerts |
Critical notifications where duplicates acceptable |
Deduplication Pseudocode
def check_deduplication(user_id, notification_type, idempotency_key, channel):
# 1. Compute dedup key
dedup_key = f"dedup:{user_id}:{notification_type}:{idempotency_key}"
# 2. Check if already sent within window
exists = redis.get(dedup_key)
if exists:
log_metric("notification.deduplicated", {
"user_id": user_id,
"type": notification_type,
"original_id": exists
})
return False # Skip, already sent
# 3. Mark as sent
window_seconds = get_dedup_window(notification_type) # e.g., 900 (15 min)
redis.set(dedup_key, notification_id, ex=window_seconds)
return True # OK to send
Dedup Window by Notification Type
| Notification Type |
Window |
Rationale |
| OTP / Security Code |
5 minutes |
Short window, user may request new code |
| Order Status (shipped, delivered) |
24 hours |
Should never send duplicate for same order event |
| Marketing Promo |
7 days |
Prevent re-sending same campaign |
| System Alert (downtime, maintenance) |
1 hour |
OK to resend if issue persists |
| Social Notification (like, comment) |
15 minutes |
Short window, aggregate multiple events |
Edge Cases
| Scenario |
Handling |
| Redis Cache Miss/Unavailable |
Fail-open for P0/P1 (allow duplicate), fail-closed for P2 (skip) |
| Race Condition (2 workers) |
Use Redis SETNX (set-if-not-exists) for atomic check-and-set |
| Client Retries Same Request |
If same request_id header, return 200 with original notification_id (idempotent) |
| User Triggers Same Action Twice |
Backend should use unique idempotency_key per action (e.g., order_id + timestamp) |
9. Delivery Tracking
Notification State Machine
| State |
Description |
Next States |
| QUEUED |
Enqueued in priority queue, awaiting worker |
PROCESSING, SKIPPED (rate limit/opt-out) |
| PROCESSING |
Worker picked up, checking preferences/rendering |
SENT, SKIPPED, FAILED |
| SKIPPED |
Not sent due to user preference, deduplication, or quiet hours |
Terminal state |
| SENT |
Successfully sent to provider, awaiting delivery confirmation |
DELIVERED, FAILED, BOUNCED |
| DELIVERED |
Provider confirmed delivery to device/inbox |
READ (push/in-app), CLICKED (email), Terminal (SMS) |
| READ |
User opened notification (push) or email |
CLICKED (if link present) |
| CLICKED |
User clicked link in notification |
Terminal state |
| FAILED |
Transient error (timeout, 503), will retry |
RETRYING, DLQ (after max retries) |
| BOUNCED |
Permanent failure (invalid email, unregistered device token) |
Terminal state, update suppression list |
| RETRYING |
In retry queue with exponential backoff |
SENT, FAILED, DLQ |
| DLQ |
Dead letter queue after max retries, requires manual review |
Terminal state (can replay manually) |
Delivery Log Schema (Cassandra)
| Column |
Type |
Description |
| notification_id |
UUID (PK) |
Unique notification identifier |
| user_id |
String (indexed) |
Recipient user ID |
| channel |
String |
push, sms, email, in_app |
| state |
String |
Current state (SENT, DELIVERED, etc.) |
| state_history |
List<Map> |
[{state: "QUEUED", timestamp: "..."}, {state: "SENT", ...}] |
| priority |
String |
P0, P1, P2 |
| provider |
String |
APNs, FCM, Twilio, SendGrid |
| provider_msg_id |
String |
Provider's message ID (for tracking) |
| retry_count |
Int |
Number of retry attempts |
| error_message |
String |
Error details if failed |
| created_at |
Timestamp |
Notification creation time |
| sent_at |
Timestamp |
Time sent to provider |
| delivered_at |
Timestamp |
Time delivered (from provider webhook) |
| read_at |
Timestamp |
Time opened/read by user |
Retry Policy
| Priority |
Max Retries |
Backoff Strategy |
Total Max Duration |
| P0 (Critical) |
5 |
1s, 2s, 4s, 8s, 16s (exponential) |
31 seconds |
| P1 (Transactional) |
3 |
2s, 4s, 8s (exponential) |
14 seconds |
| P2 (Marketing) |
2 |
5s, 20s (exponential) |
25 seconds |
Provider Webhook Handling
| Provider |
Webhook Events |
Payload |
| APNs |
No webhook (sync response only) |
Poll APNs feedback service for failed tokens |
| FCM |
No webhook (sync response only) |
Response includes delivery status, update immediately |
| Twilio |
sent, delivered, failed, undelivered |
{MessageSid, MessageStatus, ErrorCode} |
| SendGrid |
delivered, open, click, bounce, dropped, spam_report |
{sg_message_id, event, timestamp, email} |
| AWS SES |
send, delivery, bounce, complaint, open, click (via SNS) |
{messageId, notificationType, timestamp} |
Retry Logic Pseudocode
def handle_notification(msg):
try:
send_to_provider(msg)
update_state(msg.notification_id, "SENT")
except TransientError as e:
if msg.retry_count >= max_retries(msg.priority):
send_to_dlq(msg)
update_state(msg.notification_id, "DLQ")
else:
backoff = calculate_backoff(msg.retry_count, msg.priority)
msg.retry_count += 1
re_enqueue_with_delay(msg, backoff)
update_state(msg.notification_id, "RETRYING")
except PermanentError as e:
update_state(msg.notification_id, "BOUNCED")
add_to_suppression_list(msg.user_id, msg.channel)
10. Scalability
Scaling Dimensions
| Component |
Scaling Strategy |
Bottleneck |
Solution |
| Notification API |
Horizontal (stateless) |
Database writes (Cassandra) |
Async write via queue, batch inserts |
| Priority Queues (Kafka) |
Partition by user_id |
Single partition hotspot (celebrity user) |
Consistent hashing, monitor partition lag |
| Workers |
Horizontal (Kubernetes HPA) |
CPU (template rendering), network I/O (provider calls) |
Scale on queue depth, use faster machines for P0 |
| Preference Service |
Cache in Redis, read replicas |
DynamoDB read throughput |
Cache preferences (5min TTL), DynamoDB on-demand |
| Template Engine |
In-memory cache + Redis |
Rendering CPU |
Pre-compiled templates, cache rendered output |
| Channel Routers |
Horizontal per channel |
Provider rate limits |
Multiple provider accounts, round-robin, batching |
| Delivery Logs (Cassandra) |
Time-series partitioning |
Write throughput |
Async writes, batch inserts (100 msg/batch) |
| Dedup Cache (Redis) |
Redis Cluster (sharded) |
Memory (100M keys Γ 100 bytes = 10GB) |
Short TTL (15min), evict LRU |
Kafka Partitioning Strategy
| Aspect |
Strategy |
Rationale |
| Partition Key |
user_id (hash) |
Ensures ordering per user (e.g., order shipped β delivered) |
| Partition Count |
P0: 16, P1: 32, P2: 64 |
P2 has most volume, needs more parallelism |
| Consumer Groups |
One per priority level |
Isolate P0/P1/P2 worker pools |
| Consumer Count |
= Partition count (1:1 mapping) |
Max parallelism without idle consumers |
| Replication |
3x (min.insync.replicas = 2) |
Durability for P0/P1 |
| Retention |
7 days (P0/P1/P2), 30 days (DLQ) |
Allow replay for debugging |
Provider Rate Limit Handling
| Provider |
Rate Limit |
Scaling Strategy |
| APNs |
10,000 req/sec per certificate |
HTTP/2 multiplexing, multiple certificates, batch notifications |
| FCM |
600K req/min (10K/sec) |
Batch up to 500 tokens per request for multicast |
| Twilio |
1,000 req/sec per account |
Multiple subaccounts (10 accounts = 10K/sec total) |
| SendGrid |
10K req/sec via API, unlimited via SMTP |
Use SMTP for bulk (marketing), API for transactional |
| AWS SES |
14 emails/sec (increase via support request) |
Request limit increase to 1K/sec, use multiple regions |
Batch Processing for Marketing (P2)
| Optimization |
Implementation |
Benefit |
| Batch Rendering |
Render 1000 templates in parallel, cache common parts |
10x faster than serial rendering |
| Batch Provider Calls |
FCM multicast (500 tokens), SendGrid batch API (1000 emails) |
Reduce API overhead, 20x throughput |
| Preference Batch Fetch |
DynamoDB BatchGetItem (100 users at once) |
Reduce DB round trips |
| Dedup Batch Check |
Redis MGET (100 keys), pipeline SET commands |
5x faster than per-message check |
| Async Logging |
Buffer 100 log entries, batch write to Cassandra |
Reduce write load |
Auto-Scaling Rules (Kubernetes HPA)
| Worker Pool |
Metric |
Threshold |
Min/Max Pods |
| P0 Workers |
Kafka consumer lag |
> 1000 messages or latency > 5s |
Min: 10, Max: 50 |
| P1 Workers |
Kafka consumer lag |
> 5000 messages or latency > 10s |
Min: 20, Max: 200 |
| P2 Workers |
Kafka consumer lag + CPU |
> 10000 messages or CPU > 70% |
Min: 10, Max: 100 |
Load Testing Results (Target: 10K req/sec)
| Component |
Throughput |
Latency (p99) |
Resource Usage |
| Notification API (10 nodes) |
12K req/sec |
50ms |
CPU 60%, Memory 2GB/node |
| Kafka (16 partitions, P1 queue) |
15K msg/sec write |
5ms |
Network 100MB/s, Disk I/O 20MB/s |
| P1 Workers (200 pods) |
10K msg/sec |
200ms (end-to-end) |
CPU 50%, Memory 512MB/pod |
| Redis (Dedup Cache, 3-node cluster) |
50K ops/sec |
1ms |
Memory 8GB (3M keys), CPU 30% |
| DynamoDB (Preferences, on-demand) |
10K reads/sec (95% cache hit) |
10ms |
On-demand pricing, $50/day |
| Cassandra (Delivery Logs, 6 nodes) |
20K writes/sec |
5ms |
CPU 40%, Disk I/O 50MB/s |
Interview Callouts
Key Discussion Points
| Topic |
Question |
Answer |
| Architecture Choice |
Why queue-based vs direct send? |
Decouples producers from delivery logic, enables priority handling, supports retries, prevents cascading failures, allows horizontal scaling. |
| Priority Queues |
Why separate queues per priority? |
Prevents P2 (marketing) from starving P0 (critical). Dedicated worker pools ensure SLA. Simple to implement vs priority heap. |
| Kafka vs SQS |
Which queue technology? |
Kafka: better for high throughput (10K/sec+), replay capability, partitioning. SQS: simpler, managed, good for lower scale (5K/sec). |
| Partitioning |
How to partition Kafka? |
Partition by user_id (hash) to ensure per-user ordering. Celebrity users create hotspots, monitor lag per partition, use more partitions. |
| Deduplication |
How to prevent duplicates? |
Redis SET with idempotency key + sliding window TTL. Client provides key or auto-generate from content hash. Fail-open for P0. |
| Rate Limiting |
Rate limit at what level? |
API level (per service), user level (per channel), provider level (avoid hitting provider limits). P0 bypasses user limits. |
| User Preferences |
How to handle opt-out? |
Check DynamoDB before sending, cache in Redis (5min TTL). P0 (critical security) may bypass opt-out. Legal: honor unsubscribe. |
| Quiet Hours |
How to defer notifications? |
Convert to user timezone, check if in quiet hours, re-queue with delay (Kafka scheduled message or SQS visibility timeout). P2 only. |
| Template Management |
How to scale template rendering? |
Cache templates in Redis (L2) + worker memory (L1). Pre-compile templates. For P2, batch render 1000 at once. Use CDN for images. |
| Multi-Channel |
Send to all channels or user picks? |
User preferences specify channels per notification type. Backend routes to multiple channels if all opted in. Dedup per channel. |
| Provider Failover |
What if Twilio is down? |
Primary/secondary providers per channel. If primary fails (timeout, 503), failover to secondary. Track provider health, circuit breaker. |
| Retries |
How many retries? |
P0: 5 retries (31s total), P1: 3 retries (14s), P2: 2 retries (25s). Exponential backoff. After max retries, DLQ for manual review. |
| Delivery Tracking |
How to track delivery? |
Store state transitions in Cassandra (append-only). Listen to provider webhooks (Twilio, SendGrid). For APNs/FCM, sync response only. |
| Webhook Security |
How to verify webhooks? |
Validate HMAC signature (SendGrid, Twilio), IP whitelist, check timestamp to prevent replay attacks. |
| Analytics |
What metrics to track? |
Send rate, delivery rate, open rate, click rate, bounce rate, latency (p50/p99), error rate, DLQ size. Alert on anomalies (spike in bounces). |
| Cost Optimization |
How to reduce costs? |
Batch P2 (marketing) messages, use SMTP for bulk email, cache preferences, deduplicate aggressively, use cheaper providers for P2. |
| Security |
How to prevent abuse? |
API rate limiting, validate notification content (no spam/phishing), encrypt PII (email, phone), RBAC for template management. |
| Scalability Bottleneck |
What will break first at 100K/sec? |
Provider rate limits (need 10x Twilio accounts), DynamoDB read throughput (cache aggressively), Kafka write throughput (use more partitions). |
Follow-Up Questions to Expect
| Question |
Talking Points |
| "How would you handle 1M concurrent WebSocket connections for in-app?" |
Use WebSocket gateway (API Gateway, Socket.io cluster), sticky sessions (ALB), presence tracking in Redis, fan-out for 1:N messages (pub/sub), offline inbox for disconnected users. |
| "What if a celebrity user has 100M followers (send notification to all)?" |
Segment into batches (1M per batch), fan-out worker pool, use provider batch APIs (FCM multicast 500 at once), spread over 1 hour to avoid provider rate limits, pre-warm workers. |
| "How to support A/B testing for notification content?" |
Template versioning, assign user to variant (hash user_id % 100), track metrics per variant (open rate, click rate), auto-promote winner after statistical significance. |
| "How to handle timezone-aware scheduled notifications (e.g., send at 9am local time)?" |
Store user timezone in profile, scheduler service computes send time per user, enqueues at UTC time, workers send immediately (no further delay). |
| "What if DynamoDB (preferences) is down?" |
P0: fail-open (send anyway), P1: retry 3x then fail-open, P2: fail-closed (skip). Circuit breaker to stop reading preferences if error rate >10%. |
| "How to aggregate multiple notifications (e.g., 'You have 5 new messages')?" |
Debounce window (5 min), collect events in Redis, trigger aggregation job, send single notification with count. For in-app, use badge count. |
| "How to support rich push (images, buttons)?" |
APNs: mutable-content + Notification Service Extension, download image from URL. FCM: include image URL in payload. Template specifies media URLs. |
| "How to guarantee exactly-once delivery?" |
Hard in distributed systems. We guarantee at-least-once (retries + dedup). For exactly-once, need provider acknowledgment + distributed transaction (2PC), too slow. |
Trade-Offs to Discuss
| Decision |
Trade-Off |
| Queue-based architecture |
Pro: Scalable, retry-friendly. Con: Adds latency (queue delay), complexity (Kafka ops). |
| Separate P0/P1/P2 queues |
Pro: Guaranteed SLA for critical. Con: Resource inefficiency (idle P0 workers), more infrastructure. |
| Kafka vs SQS |
Kafka: Higher throughput, replay, ordering. SQS: Simpler, managed, lower scale. Choose based on volume. |
| Redis deduplication |
Pro: Fast, simple. Con: Cache miss = duplicate or drop. Fail-open for P0, fail-closed for P2. |
| Preference caching |
Pro: Low latency, reduced DB load. Con: Stale data (up to 5min), user opt-out not immediate. |
| Provider failover |
Pro: Reliability. Con: Cost (pay for 2 providers), complexity (maintain integrations), data inconsistency (same message via 2 providers if failover timing is wrong). |
| Batch processing (P2) |
Pro: 20x throughput. Con: Higher latency (wait for batch), complexity (batch assembly logic). |
| Cassandra for logs |
Pro: High write throughput, time-series. Con: Ops burden (vs managed DynamoDB), eventual consistency. |
Webhook Security & Reliability Patterns
Signature Verification (HMAC-SHA256)
| Aspect |
Implementation |
Purpose |
| Sender |
Compute HMAC-SHA256(payload, secret), send in X-Webhook-Signature header |
Prove authenticity: only sender with secret can generate valid signature |
| Receiver |
Recompute HMAC-SHA256(payload, secret), compare with header |
Verify webhook came from trusted sender, not attacker |
| Secret Management |
Store in secrets manager (AWS Secrets Manager, Vault), rotate every 90 days |
Prevent compromise, limit blast radius |
| Header Name |
X-Webhook-Signature (Stripe), X-Hub-Signature-256 (GitHub), X-Twilio-Signature |
Provider-specific naming, check docs |
Signature Verification Pseudocode
// Sender (webhook publisher)
def send_webhook(url, payload, secret):
body = json.dumps(payload)
timestamp = int(time.time())
signed_payload = f"{timestamp}.{body}"
signature = hmac_sha256(signed_payload, secret)
headers = {
"X-Webhook-Signature": signature,
"X-Webhook-Timestamp": timestamp,
"Content-Type": "application/json"
}
response = requests.post(url, data=body, headers=headers)
return response
// Receiver (webhook consumer)
def verify_webhook(request, secret):
# 1. Extract signature and timestamp from headers
received_signature = request.headers["X-Webhook-Signature"]
timestamp = int(request.headers["X-Webhook-Timestamp"])
body = request.body
# 2. Reject if timestamp > 5 minutes old (replay attack prevention)
if abs(time.time() - timestamp) > 300:
return False, "Timestamp too old, possible replay attack"
# 3. Recompute signature
signed_payload = f"{timestamp}.{body}"
expected_signature = hmac_sha256(signed_payload, secret)
# 4. Compare signatures (constant-time comparison to prevent timing attacks)
if not hmac.compare_digest(received_signature, expected_signature):
return False, "Invalid signature"
return True, "Valid webhook"
Replay Attack Prevention
| Attack Vector |
How It Works |
Defense |
| Replay Attack |
Attacker intercepts valid webhook, replays it hours later (e.g., "payment successful" β duplicate credit) |
Include timestamp in signature, reject if > 5 minutes old |
| MITM (Man-in-the-Middle) |
Attacker intercepts webhook over HTTP, modifies payload |
Require HTTPS (TLS 1.2+), reject HTTP webhooks |
| Signature Stripping |
Attacker removes signature header, sends unsigned payload |
Reject requests without signature header (fail-closed) |
| Timing Attack |
Attacker measures signature comparison time to guess secret byte-by-byte |
Use constant-time comparison (hmac.compare_digest, not ==) |
Retry Strategy (At-Least-Once Delivery)
| Attempt |
Delay |
Cumulative Time |
HTTP Codes to Retry |
| 1 |
Immediate |
0s |
Initial attempt |
| 2 |
1 minute |
1m |
408 (Timeout), 429 (Rate Limit), 500 (Internal Error), 502/503/504 (Service Unavailable) |
| 3 |
5 minutes |
6m |
Same as above |
| 4 |
30 minutes |
36m |
Same as above |
| 5 |
2 hours |
2h 36m |
Same as above |
| 6 |
12 hours |
14h 36m |
Same as above |
| 7 |
24 hours |
38h 36m |
Same as above (final attempt) |
| DLQ |
- |
After attempt 7 |
Store in Dead Letter Queue for manual replay or investigation |
Do NOT retry: 400 (Bad Request), 401/403 (Auth Error), 404 (Not Found), 410 (Gone) β these are permanent failures.
Idempotency (Preventing Duplicate Processing)
| Aspect |
Implementation |
Details |
| Event ID |
Include unique event_id in webhook payload |
event_id = UUID or "{resource_type}_{resource_id}_{event_type}" (e.g., "order_12345_shipped") |
| Receiver Deduplication |
Check if event_id already processed, skip if duplicate |
Store processed event IDs in DB (TTL 7 days) or Redis SET |
| Atomic Processing |
Mark event as processed in same transaction as business logic |
BEGIN TRANSACTION; process_order(event); INSERT INTO processed_events(event_id); COMMIT; |
| Response Code |
Return 200 OK for both first and duplicate delivery |
Idempotent: same request multiple times = same outcome, no error |
Idempotency Pseudocode
def handle_webhook(event):
event_id = event["id"]
# 1. Check if already processed
if redis.exists(f"processed_event:{event_id}"):
log.info(f"Duplicate webhook {event_id}, skipping")
return 200 # Idempotent: return success
# 2. Process event + mark as processed atomically
try:
with db.transaction():
process_event(event) # Business logic (update order, send email, etc.)
db.execute("INSERT INTO processed_events (event_id, processed_at) VALUES (?, ?)",
event_id, now())
# 3. Cache for fast duplicate check (Redis as L1, DB as L2)
redis.setex(f"processed_event:{event_id}", 86400 * 7, "1") # 7 day TTL
return 200
except Exception as e:
log.error(f"Failed to process webhook {event_id}: {e}")
return 500 # Trigger retry
Dead Letter Queue (DLQ) Strategy
| Aspect |
Implementation |
Purpose |
| When to DLQ |
After max retries (7 attempts over 38 hours) |
Prevent infinite retry loops, isolate persistent failures |
| Storage |
Separate Kafka topic, SQS DLQ, or DB table |
Durable storage for manual review |
| DLQ Entry |
Store: event_id, payload, error_message, retry_count, last_attempt_at |
Debugging context for ops team |
| Alerts |
Alert on DLQ size > 100 or DLQ rate > 1% |
Indicates systemic issue (receiver down, bug in handler) |
| Replay |
Manual replay after fixing issue, or automated replay job |
Recover from transient outages (receiver was down for 2 days) |
| Retention |
30 days (long enough to debug, short enough to not explode storage) |
After 30 days, archive to S3 or delete |
Webhook Versioning (Avoiding Breaking Changes)
| Strategy |
Implementation |
Use Case |
| URL Versioning |
https://api.example.com/v1/webhooks, /v2/webhooks |
Simplest: receivers register with versioned URL, sender routes to correct version |
| Parallel Versions |
Sender POSTs to both /v1 and /v2 during migration period (3-6 months) |
Allows receivers to migrate at their own pace, no forced upgrade |
| Backward Compatibility |
Add new fields, never remove or rename. Use versioned event types (order.shipped.v2) |
Avoid versioning entirely if possible (Stripe approach) |
| Header Versioning |
X-Webhook-Version: 2, receiver checks header and handles accordingly |
Single URL, version in header. Cleaner URLs, harder to route in API gateway. |
| Sunset Policy |
Announce v1 sunset 6 months in advance, send deprecation warnings in webhook headers |
Force migration after grace period (Sunset: 2027-01-01 header) |
Real-World Example: Stripe Webhook Security
// Stripe webhook signature verification (Node.js)
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
app.post('/webhooks/stripe', express.raw({type: 'application/json'}), (req, res) => {
const sig = req.headers['stripe-signature'];
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET;
let event;
try {
// Stripe library verifies signature + timestamp automatically
event = stripe.webhooks.constructEvent(req.body, sig, endpointSecret);
} catch (err) {
console.log(`Webhook signature verification failed: ${err.message}`);
return res.status(400).send(`Webhook Error: ${err.message}`);
}
// Check idempotency (Stripe sends event.id)
if (await isProcessed(event.id)) {
return res.status(200).send('Duplicate event, already processed');
}
// Handle event
switch (event.type) {
case 'payment_intent.succeeded':
await handlePaymentSuccess(event.data.object);
break;
case 'payment_intent.failed':
await handlePaymentFailure(event.data.object);
break;
default:
console.log(`Unhandled event type: ${event.type}`);
}
// Mark as processed
await markProcessed(event.id);
// Return 200 to acknowledge receipt (prevents retries)
res.status(200).send('Webhook received');
});
Webhook Reliability Comparison
| Provider |
Retry Strategy |
Max Retries |
Signature Algorithm |
| Stripe |
Exponential backoff over 3 days |
~16 attempts |
HMAC-SHA256 with timestamp |
| Twilio |
Retry every 4 hours for 72 hours |
18 attempts |
HMAC-SHA256 (X-Twilio-Signature) |
| GitHub |
3 retries with exponential backoff (1min, 2min, 4min) |
3 attempts |
HMAC-SHA256 (X-Hub-Signature-256) |
| SendGrid |
Retry up to 24 hours with exponential backoff |
10 attempts |
Optional signature verification |
Interview Question: How would you secure webhooks and ensure reliable delivery?
Strong Answer: "Webhook reliability = at-least-once delivery + idempotent receiver + HMAC signature. For security, I'd use HMAC-SHA256 signature verification: the sender computes HMAC(payload + timestamp, secret) and sends it in the X-Webhook-Signature header. The receiver recomputes the signature with the shared secret and comparesβif they don't match, reject the webhook. Including the timestamp in the signature prevents replay attacks: reject if the timestamp is more than 5 minutes old. For reliability, implement exponential backoff retries: attempt 1 (immediate), 2 (1min), 3 (5min), 4 (30min), 5 (2hr), 6 (12hr), 7 (24hr). After 7 failures, send to a Dead Letter Queue for manual review. The receiver must be idempotentβinclude an event_id in the payload and deduplicate on the receiver side. Store processed event IDs in Redis or a DB table, and return 200 OK for duplicate deliveries to stop retries. For versioning, use /v1/webhooks and /v2/webhooks URLs, run both in parallel during migration, then sunset v1 after 6 months with advance notice."