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."