Design multi-channel notification delivery with cascading fallback
If push delivery fails (app uninstalled, token expired), fall back to SMS within 30s. Chain: push → wait 30s → SMS → wait 60s → email. Each channel has different delivery guarantees. How do you ensure every notification reaches the user via at least one channel?
What the fallback system must do · guaranteed delivery via channel escalation
Quality constraints for cascading fallback delivery
| Property | Target | Why It Matters / Design Impact |
|---|---|---|
| Guaranteed Delivery | 99.9% via at least one channel | Fallback chain ensures if push fails, SMS catches it. If SMS fails, email is near-guaranteed. Must never silently drop. |
| Max Fallback Time | <2 minutes end-to-end | Push timeout 30s + SMS timeout 60s + email send = ~2 min worst case. User gets notification within 2 min regardless. |
| Zero Duplicates | 0 duplicate deliveries | Atomic state transitions. If delivery receipt arrives while timeout fires, only one wins. Distributed lock on state transition. |
| State Consistency | Exactly one active attempt | At any moment, only one channel attempt is active per notification. State machine enforces this invariant. |
| Receipt Processing | <1s from provider webhook | Fast receipt processing cancels pending timers quickly, preventing unnecessary escalation to costlier channels. |
| Scalability | 100M notifications/day | 15M fallback to SMS daily (15% failure). State stored in Redis for sub-ms transitions. |
Derive fallback infrastructure sizing from constraints
| Step | What to Derive | Calculation | Result | Design Decision |
|---|---|---|---|---|
| 1 | Notifications needing fallback/day | 100M × 15% push failure | 15M fall to SMS/day | SMS worker pool must handle 15M/day = ~174/sec additional load |
| 2 | SMS failures needing email | 15M × 5% SMS failure | 750K fall to email/day | Email is last resort — nearly 100% delivery rate via SendGrid/SES |
| 3 | Concurrent pending timers | ~1,157 notif/sec × 30s window | ~35K pending timers | Redis sorted set with score=expire_time. Timer worker polls every 1s. |
| 4 | State transitions/sec | 1,157 new + 174 escalations + 1,157 receipts | ~2,500 transitions/sec | Redis atomic operations (WATCH/MULTI or Lua script) for consistency |
| 5 | State storage (Redis) | 35K active × 512 bytes per state object | ~18MB active state | Fits easily in single Redis instance. Cluster for HA, not capacity. |
| 6 | Delivery receipt webhook volume | 100M deliveries/day × 80% provide receipt | ~926 webhooks/sec | Webhook ingestion service with Kafka buffer for spike absorption |
| 7 | Cost impact of fallback | 15M SMS × $0.01/SMS + 750K emails × $0.001 | $150K+/day SMS cost | Optimize push reliability first. Every 1% push improvement saves $10K/day. |
Endpoints for fallback-enabled notification delivery and status tracking
{
"user_id": "u_abc123",
"template_id": "otp_code",
"params": { "code": "847291" },
"fallback_chain": [
{ "channel": "push", "timeout_sec": 30 },
{ "channel": "sms", "timeout_sec": 60 },
{ "channel": "email", "timeout_sec": null }
],
"dedup_key": "otp:u_abc123:847291",
"max_ttl_sec": 300
}
202 Accepted · { "notification_id": "n_fb001", "state": "push_pending" }GET /v1/notifications/n_fb001/status
Response 200:
{
"notification_id": "n_fb001",
"current_state": "delivered",
"delivered_via": "sms",
"attempts": [
{ "channel": "push", "status": "timeout", "started_at": "...", "ended_at": "..." },
{ "channel": "sms", "status": "delivered", "started_at": "...", "delivered_at": "..." }
],
"total_time_ms": 34521
}
// APNs delivery receipt
{ "provider": "apns", "notification_id": "n_fb001", "status": "delivered", "timestamp": "..." }
// Twilio status callback
{ "provider": "twilio", "MessageSid": "SM...", "MessageStatus": "delivered", "To": "+1..." }
// SendGrid event
{ "provider": "sendgrid", "event": "delivered", "sg_message_id": "...", "timestamp": ... }
State machine storage and delivery tracking structures
HSET notification_state:{notification_id}
user_id "u_abc123"
current_state "sms_pending" // pending|push_sent|push_failed|sms_sent|sms_failed|email_sent|delivered|expired
current_channel "sms"
attempt_index 1 // which step in fallback_chain
fallback_chain '[{"channel":"push","timeout_sec":30},{"channel":"sms","timeout_sec":60},{"channel":"email"}]'
created_at "1705312200000"
last_transition "1705312230000"
delivered_at ""
dedup_key "otp:u_abc123:847291"
EXPIRE notification_state:{notification_id} 600 // TTL = max_ttl_sec + buffer
// Score = Unix timestamp when timeout fires
// Member = notification_id
ZADD delayed_queue 1705312230 "n_fb001" // fires at created_at + 30s (push timeout)
ZADD delayed_queue 1705312290 "n_fb002" // fires at created_at + 60s (sms timeout)
// Timer worker polls every second:
ZRANGEBYSCORE delayed_queue 0 {current_timestamp} LIMIT 0 100
// Process expired entries, then ZREM them
CREATE TABLE delivery_attempts (
notification_id UUID,
attempt_num INT,
channel TEXT, -- push | sms | email
status TEXT, -- sent | delivered | failed | timeout
provider TEXT, -- apns | fcm | twilio | sendgrid
provider_msg_id TEXT,
error_code TEXT,
started_at TIMESTAMP,
completed_at TIMESTAMP,
PRIMARY KEY ((notification_id), attempt_num)
) WITH default_time_to_live = 2592000; -- 30 days
// Valid state transitions (enforced by Lua script):
pending → push_sent (push worker picks up)
push_sent → delivered (delivery receipt received)
push_sent → push_timeout (30s timer fires, no receipt)
push_timeout → sms_sent (sms worker picks up)
sms_sent → delivered (delivery receipt received)
sms_sent → sms_timeout (60s timer fires, no receipt)
sms_timeout → email_sent (email worker picks up)
email_sent → delivered (delivery receipt received)
* → expired (max_ttl exceeded)
Notification created → Push attempt → Timer → Check status → Escalate or confirm delivery
Tradeoffs for cascading fallback architecture
| Decision | Options Considered | Chosen | Why |
|---|---|---|---|
| State Storage | PostgreSQL, DynamoDB, Redis | Redis with Lua scripts | Atomic CAS required for race condition safety. Sub-ms transitions. TTL for auto-cleanup. State is ephemeral (max 5 min). |
| Timer Mechanism | OS timers, SQS delay, Redis ZSET | Redis Sorted Set | Score = expiry timestamp. Poll every 1s with ZRANGEBYSCORE. Survives worker restart. Easy to cancel (ZREM). |
| Timeout Duration | 10s/30s/60s, 30s/60s/∞, adaptive | 30s push / 60s SMS | 95% of push deliveries complete in <5s. 30s gives margin for provider delays without excessive wait. SMS is slower (carrier delay). |
| Duplicate Prevention | Distributed lock, optimistic concurrency, CAS | Lua CAS (compare-and-swap) | Single Redis command, no external lock service. Atomic read-check-write in one operation. No deadlock risk. |
| Receipt Processing | Synchronous webhook, async queue | Kafka-buffered async | Provider webhooks can burst. Kafka absorbs spikes. Consumer processes receipts and updates state. Retry on failure. |
Failure modes specific to cascading fallback delivery
| Scenario | Problem | Solution | Recovery |
|---|---|---|---|
| Late Delivery Receipt | Push delivered at T=29s but receipt arrives at T=31s (after timeout). SMS already triggered. | CAS in Lua script: timer checks state before escalating. If push was delivered between timer fire and CAS, escalation is cancelled. | Zero duplicates via atomic CAS |
| Provider Webhook Down | Twilio can't reach our webhook — delivery receipt never arrives. | Don't rely solely on receipts. After max_ttl (5 min), mark as "presumed_delivered" if provider returned 200 on send. Reconcile async. | Eventual consistency via reconciliation |
| All Channels Fail | Push token expired, phone number invalid, email bounced. | After exhausting all channels in chain, mark as "undeliverable". Publish event for user health service. Flag user for contact info update. | Undeliverable → user flagged |
| Redis Failover | Redis primary fails, state lost during failover window. | Redis Sentinel with min-slaves-to-write=1. State also written to Kafka (event log). On recovery, replay Kafka events to rebuild state. | <5s failover + replay |
| Timer Worker Crash | Timer worker dies, pending timers not polled. | Multiple timer workers compete on ZRANGEBYSCORE with LIMIT + ZREM (atomic claim). Any worker can process any timer. No single-worker dependency. | <2s other worker picks up |
| Notification Expired | OTP code only valid for 5 min. Fallback chain takes 2 min. What if user reads at T=4 min? | Include expiry_at in notification content. Client-side: don't display expired notifications. Server-side: max_ttl cancels remaining chain on expiry. | Auto-expire at max_ttl |
5 key points for notification fallback design interviews