Design a real-time in-app notification feed with badge counts and WebSocket push
How do you build the notification bell/badge that shows unread count, displays a chronological feed, supports mark-as-read, and pushes new notifications in real-time via WebSocket to 10M concurrent connections?
What the notification feed system must do · core feed behaviours
Quality constraints shaping the notification feed architecture
| Property | Target | Why It Matters / Design Impact |
|---|---|---|
| Feed Read Latency | <50ms P99 | Users expect instant feed display on bell click. Redis ZREVRANGEBYSCORE provides sub-ms response. |
| WebSocket Push | <200ms end-to-end | New notification must update badge within 200ms of creation. Requires efficient connection routing. |
| Concurrent Connections | 10M WebSocket | Each connection holds ~10KB state. 10M × 10KB = 100GB total across WebSocket fleet. |
| Unread Counter Consistency | Strongly consistent | Badge count must be accurate. Use Redis INCR/DECR atomic operations. No stale reads. |
| Multi-Device Sync | <1s propagation | Mark-as-read on phone must update badge on web within 1 second. |
| Availability | 99.99% | Notification feed is always-visible UI element. Degraded mode: show cached feed if Redis unavailable. |
| Feed Size | 200 items max | Bounded feed prevents unbounded Redis memory growth. ZREMRANGEBYRANK trims on each write. |
Derive infrastructure sizing from given constraints. Show this reasoning in an interview.
| Step | What to Derive | Calculation | Result | Design Decision |
|---|---|---|---|---|
| 1 | Feed writes/sec | 2B / 86,400s | ~23K/sec | ZADD to per-user sorted set. Redis cluster handles this easily. |
| 2 | WebSocket push messages/sec | 23K new notifications/sec × avg 1.2 connected devices/user | ~28K pushes/sec | WebSocket gateway routes by user_id → connection shard. |
| 3 | Redis memory (all feeds) | 500M users × 200 items × 128 bytes avg | ~12.8TB total | But only active users (20%) in Redis. Cold users on disk. ~2.5TB hot. |
| 4 | Hot user feeds in Redis | 100M active users × 200 items × 128B | ~2.5TB | Redis cluster: 256 shards × 10GB each. Shard by user_id hash. |
| 5 | WebSocket server fleet | 10M connections / 100K connections per server | ~100 WS servers | Each server: 64GB RAM, handles 100K persistent connections. |
| 6 | Feed read QPS | 500M DAU × 5 feed opens/day / 86,400s | ~29K reads/sec | ZREVRANGEBYSCORE with cursor. Sub-ms per read from Redis. |
| 7 | Unread counter storage | 500M users × 8 bytes (integer) | ~4GB | Separate Redis cluster for counters. Atomic INCR/DECR. |
| 8 | Mark-as-read QPS | ~50% of feed opens trigger mark-as-read = ~14.5K/sec | ~14.5K/sec | Batch mark-all-as-read: single SET unread:{user_id} 0 |
Notification feed endpoints · REST for reads/writes, WebSocket for real-time push
GET /v1/notifications/u_abc123/feed?limit=20&cursor=MTcxMDQ5NTAwMA==
Response 200:
{
"notifications": [
{
"id": "n_001", "type": "like_aggregated",
"title": "Jane and 4 others liked your post",
"body": "My vacation photos...",
"actors": [{"id": "u_456", "name": "Jane", "avatar": "..."}],
"entity": {"type": "post", "id": "p_789"},
"read": false,
"created_at": "2024-03-15T10:30:00Z"
}
],
"next_cursor": "MTcxMDQ5NDAwMA==",
"has_more": true,
"unread_count": 7
}
GET /v1/notifications/u_abc123/unread-count
Response 200: { "unread_count": 7, "last_updated": "2024-03-15T10:30:00Z" }
PUT /v1/notifications/u_abc123/read
{
"notification_ids": ["n_001", "n_002"], // specific IDs, or...
"mark_all": false, // true = mark all as read
"read_before": null // mark all before timestamp
}
Response 200: { "marked_count": 2, "unread_count": 5 }
// Server → Client messages:
{ "type": "badge_update", "unread_count": 8 }
{ "type": "new_notification", "notification": { "id": "n_003", "title": "...", ... } }
{ "type": "read_sync", "notification_ids": ["n_001"], "unread_count": 7 }
// Client → Server messages:
{ "type": "ack", "notification_id": "n_003" }
{ "type": "mark_read", "notification_ids": ["n_003"] }
Redis structures and persistent storage for the notification feed
// Per-user feed — sorted by timestamp (score = created_at unix ms)
// Key: feed:{user_id}
ZADD feed:u_abc123 1710495000000 "n_001"
ZADD feed:u_abc123 1710494000000 "n_002"
// Trim to max 200 entries on each write
ZREMRANGEBYRANK feed:u_abc123 0 -201
// Pagination: cursor-based using score (timestamp)
ZREVRANGEBYSCORE feed:u_abc123 {cursor_ts} -inf LIMIT 0 20
// Notification content stored separately (referenced by ID from feed)
HSET notif:n_001
"type" "like_aggregated"
"title" "Jane and 4 others liked your post"
"body" "My vacation photos..."
"actors" "[{\"id\":\"u_456\",\"name\":\"Jane\"}]"
"entity_type" "post"
"entity_id" "p_789"
"read" "0"
"created_at" "1710495000000"
"collapse_key" "like:p_789:u_abc123"
EXPIRE notif:n_001 2592000 // 30 day TTL
// Atomic counter per user
SET unread:u_abc123 7
// On new notification:
INCR unread:u_abc123
// On mark-as-read (single):
DECR unread:u_abc123
// On mark-all-as-read:
SET unread:u_abc123 0
// Safety: counter can't go below 0
// Use Lua script: if tonumber(redis.call('GET', key)) > 0 then redis.call('DECR', key) end
// Track which WS server holds which user's connection
// Key: ws_conn:{user_id}, Value: ws_server_id
HSET ws_conn:u_abc123 "device_web" "ws-server-042"
HSET ws_conn:u_abc123 "device_mobile" "ws-server-017"
EXPIRE ws_conn:u_abc123 300 // 5 min TTL, refreshed by heartbeat
// Reverse index: which users are on which server (for server shutdown drain)
SADD ws_server:ws-server-042 "u_abc123" "u_def456" ...
{
"user_id": "u_abc123", // partition key
"notification_id": "n_001", // sort key
"type": "like_aggregated",
"title": "Jane and 4 others liked your post",
"actors": [{"id": "u_456", "name": "Jane"}],
"entity": {"type": "post", "id": "p_789"},
"read": false,
"created_at": "2024-03-15T10:30:00Z",
"ttl": 1718131200 // 90-day auto-delete
}
Write Path: Notification → Redis Feed + Counter → Pub/Sub → WebSocket Push
Read Path: Client → API → Redis ZREVRANGEBYSCORE → Paginated Response
Tradeoffs made in this architecture and their rationale
| Decision | Options Considered | Chosen | Why |
|---|---|---|---|
| Feed Storage | List (LPUSH), Sorted Set (ZADD), Stream (XADD) | Redis Sorted Set | Score = timestamp enables cursor-based pagination via ZREVRANGEBYSCORE. O(log N) inserts. ZREMRANGEBYRANK for bounded size. |
| WebSocket Routing | Broadcast to all WS servers, connection registry lookup, consistent hashing | Connection registry + direct routing | Broadcasting wastes 99% of messages (user on 1 of 100 servers). Registry lookup is O(1) and routes directly to correct server. |
| Unread Counter | Count from feed (ZCOUNT), separate counter, client-side tracking | Separate atomic Redis counter | ZCOUNT is O(log N) on every badge render. Separate counter is O(1) GET. Trade off slight drift risk for massive read performance. |
| Pagination | Offset-based, cursor-based (timestamp), keyset | Cursor-based (timestamp score) | Offset breaks when new items are added mid-page. Timestamp cursor is stable — "give me 20 items before this timestamp" always works correctly. |
| Multi-Device Sync | Polling, WebSocket push, push notification | WebSocket push to all connected devices | Mark-as-read on phone → publish via Redis Pub/Sub → route to all WS servers holding user connections → instant badge update on web. |
| Hot/Cold Feed Strategy | All feeds in Redis, LRU eviction, explicit hot/cold split | Explicit: active users in Redis, cold in DynamoDB | Only 20% of users are active daily. Loading all 500M feeds into Redis wastes 10TB. Load on first access, evict after 7 days of inactivity. |
How the notification feed system handles failures gracefully
| Scenario | Problem | Solution | Recovery |
|---|---|---|---|
| WebSocket Server Crash | Server dies, 100K connections drop. Clients reconnect to other servers. | Client auto-reconnect with exponential backoff. On reconnect: register new connection, fetch missed notifications (diff feed since last_seen_ts). | <5s client reconnect |
| Redis Feed Cluster Down | Feed sorted sets unavailable. Can't read or write feed. | Fallback to DynamoDB direct read (slower, ~50ms → ~200ms). Queue writes for replay when Redis recovers. Badge count from separate counter cluster. | Degraded (higher latency reads) |
| Counter Drift | Badge shows 5, but user only has 3 unread notifications. | Hourly reconciliation job for active users. On mark-all-as-read: SET counter to 0 (corrects any drift). Client can also verify on feed load. | Self-healing within 1 hour |
| Notification Flood | Viral post generates 10K notifications for one user in 1 minute. | Per-user write rate limit: max 10 feed writes/minute. Excess notifications are aggregated into batch entries. Counter still increments accurately. | Automatic rate throttling |
| Stale Connection Registry | User disconnected but registry still points to old server (no heartbeat cleanup). | 5-minute TTL on registry entries. Heartbeat refreshes TTL. If push fails (user not found on target server), remove registry entry. Next connection re-registers. | <5min auto-cleanup |
| Cold User Activation | User inactive for months returns. Feed not in Redis. | Cache miss triggers DynamoDB query → hydrate Redis feed. Takes ~200ms for first load. Subsequent reads are sub-ms from Redis. Pre-warm for predicted re-engagers. | 200ms first-load penalty |
5 key points to nail a real-time notification feed interview