Design notification aggregation — "5 people liked your post" instead of 5 separate notifications
How do you aggregate multiple engagement events (likes, comments, follows) about the same entity into a single notification — delivering the first event immediately but batching subsequent events into updates like "5 people liked your post"?
What the aggregation system must do · core batching behaviours
Quality constraints shaping the aggregation architecture
| Property | Target | Why It Matters / Design Impact |
|---|---|---|
| First-Event Latency | <3s | First like on a post must notify immediately. No batching delay for the initial event. |
| Batch Window | 15 min max | After 15 min, flush whatever is in the batch. User shouldn't wait longer than 15 min for aggregated update. |
| Throughput | 1B events/day (11.5K/sec) | Kafka partitioned by aggregation key for per-entity processing. |
| Notification Reduction | 85%+ reduction | From 1B raw events down to ~150M aggregated notifications. Reduces user notification fatigue. |
| Consistency | Count accuracy ±1 | Aggregated count may be slightly stale (±1) but must converge. Eventual consistency acceptable. |
| Collapse Guarantee | No duplicate notifications | An already-displayed "3 people liked" must update to "5 people liked", not show a separate notification. |
| Scalability | Linear with events | Partitioned by aggregation key. Adding Kafka partitions + consumers scales linearly. |
Derive infrastructure sizing from given constraints. Show this reasoning in an interview.
| Step | What to Derive | Calculation | Result | Design Decision |
|---|---|---|---|---|
| 1 | Avg events/sec | 1B / 86,400s | ~11,574/sec | Kafka with 64 partitions (key = aggregation_key hash) |
| 2 | Aggregated notifications/day | 1B / 6.7 avg events per batch | ~150M/day | 85% reduction in downstream notification volume |
| 3 | Active aggregation windows | 11,574 new entities/sec × 900s window | ~10.4M active windows | Redis hash per active window. ~10M keys at any time. |
| 4 | Redis memory for windows | 10.4M windows × 256 bytes (key + count + actors + timer) | ~2.7GB | Single Redis cluster (8 shards) with room for peak |
| 5 | Window flush rate | 10.4M windows / 900s avg lifetime | ~11,500 flushes/sec | Matches event rate. Each flush emits one aggregated notification. |
| 6 | Peak event burst | 11.5K × 10x (viral post) | ~115K/sec peak | Auto-scale consumers. Hot partition detection for viral entities. |
| 7 | Consumer instances | 64 partitions × 1 consumer per partition | 64 consumers | Each consumer processes ~180 events/sec avg. Headroom for burst. |
| 8 | Window timer storage | Redis sorted set for timer expiry, 10.4M entries | ~1.5GB | ZRANGEBYSCORE polls every 1s for expired windows |
Aggregation service internal APIs · event ingestion and window management
{
"event_id": "evt_001",
"event_type": "like", // like | comment | share | follow | mention
"actor_id": "u_actor_456",
"actor_name": "Jane Smith",
"target_user_id": "u_owner_123", // notification recipient
"entity_type": "post", // post | comment | story | profile
"entity_id": "post_789",
"entity_preview": "My vacation photos...",
"timestamp": "2024-03-15T10:30:00Z"
}
202 Accepted · { "aggregation_key": "like:post_789:u_owner_123", "window_status": "active" }GET /v1/aggregation/window/like:post_789:u_owner_123
Response 200:
{
"aggregation_key": "like:post_789:u_owner_123",
"count": 5,
"first_actor": "Jane Smith",
"recent_actors": ["Jane Smith", "Bob Lee", "Alice Kim"],
"window_opened_at": "2024-03-15T10:30:00Z",
"window_expires_at": "2024-03-15T10:45:00Z",
"first_event_delivered": true,
"last_batch_sent_at": "2024-03-15T10:35:00Z"
}
{
"aggregation_key": "like:post_789:u_owner_123",
"reason": "window_expired", // window_expired | threshold_reached | manual
"emit_notification": {
"template_id": "engagement_aggregated",
"target_user_id": "u_owner_123",
"params": {
"count": 5,
"first_actor": "Jane Smith",
"entity_type": "post",
"entity_preview": "My vacation photos..."
},
"collapse_key": "like:post_789:u_owner_123"
}
}
Redis structures and persistent store for aggregation windows
// Key: agg_window:{aggregation_key}
// aggregation_key = {event_type}:{entity_id}:{target_user_id}
HSET agg_window:like:post_789:u_owner_123
"count" "5"
"first_actor_id" "u_actor_456"
"first_actor_name" "Jane Smith"
"recent_actors" "[\"Jane Smith\",\"Bob Lee\",\"Alice Kim\"]" // last 3
"entity_type" "post"
"entity_preview" "My vacation photos..."
"window_opened" "1710495000000" // unix ms
"first_delivered" "1" // boolean: first event already sent
"last_batch_ts" "1710495300000" // last batch notification sent
EXPIRE agg_window:like:post_789:u_owner_123 960 // 16 min (window + buffer)
// Score = window_expiry_time_ms (opened_at + 15 min)
ZADD window_timers {expiry_ms} "like:post_789:u_owner_123"
// Poller runs ZRANGEBYSCORE every 1s to find expired windows
ZRANGEBYSCORE window_timers 0 {now_ms} LIMIT 0 1000
// In-app feed entry — updated in place when aggregation count changes
{
"notification_id": "n_agg_001",
"target_user_id": "u_owner_123",
"collapse_key": "like:post_789:u_owner_123", // used to find & update
"template_id": "engagement_aggregated",
"params": {
"count": 5,
"first_actor": "Jane Smith",
"others_count": 4,
"entity_type": "post",
"entity_preview": "My vacation photos..."
},
"rendered_text": "Jane Smith and 4 others liked your post",
"updated_at": "2024-03-15T10:45:00Z",
"read": false
}
// Input: raw engagement events (partitioned by aggregation_key)
Topic: engagement-events
Key: "like:post_789:u_owner_123"
Value: { event_id, actor_id, actor_name, timestamp, ... }
// Output: aggregated notifications (to notification service)
Topic: aggregated-notifications
Key: "u_owner_123"
Value: { template_id, params, collapse_key, ... }
Event Stream → Aggregation Processor → Window Manager → Batch Flusher → Notification Service
Tradeoffs made in this architecture and their rationale
| Decision | Options Considered | Chosen | Why |
|---|---|---|---|
| First-Event Strategy | Always batch (delay all), always immediate, hybrid (first immediate + batch rest) | Hybrid: first immediate, rest batched | Users want instant feedback on first interaction. But 50 separate "X liked your post" is spam. Hybrid gives both immediacy and aggregation. |
| Window Duration | 5 min, 15 min, 30 min, adaptive | 15 min fixed window | Short enough that users get timely updates. Long enough to meaningfully aggregate. Adaptive adds complexity without proportional UX benefit. |
| Aggregation Key | Per-entity only, per-entity-per-type, per-user-per-entity-per-type | {event_type}:{entity_id}:{target_user_id} | Separate likes from comments on same post. Per-user ensures personalized notification per recipient. |
| Collapse Behavior | New notification each batch, update existing, replace existing | Update existing notification in-place | User sees one entry per entity in feed, count updates. No notification list pollution. Push notification updates silently. |
| Intermediate Updates | Only on window expiry, on every event, on thresholds (10, 50, 100) | Threshold-based intermediate updates | For viral posts with 1000+ likes, update at thresholds (10, 50, 100, 500) so user sees activity without waiting 15 min. |
| Window State Store | In-memory (Flink state), Redis, database | Redis Hash per window | Fast atomic HINCRBY for count. EXPIRE handles cleanup. Survives consumer restart (unlike in-memory Flink state). |
How the aggregation system handles failures and edge cases
| Scenario | Problem | Solution | Recovery |
|---|---|---|---|
| Consumer Restart | Aggregation consumer restarts mid-window. In-memory state lost. | All window state is in Redis (not in-memory). Consumer reads Redis state on each event. Stateless consumer design. | Instant recovery (Redis has state) |
| Window Timer Miss | Timer poller misses a window expiry (poller crash/lag). | Redis EXPIRE on window hash ensures data is cleaned even if timer missed. Catch-up: scan for windows older than 15 min + buffer. | <30s catch-up on poller recovery |
| Unlike/Undo Event | User unlikes a post after being counted in aggregation. | Decrement count in window (HINCRBY -1). If count drops to 0, cancel the pending aggregated notification. Don't send "0 people liked". | Real-time count correction |
| Viral Entity (10M events) | Single entity gets millions of events, overwhelming one partition. | Hot entity detection: >1K events/min triggers sampling mode. Count = sample_count × sample_rate. Accuracy ±5% is acceptable for "~10K people liked". | Automatic sampling mode |
| Redis Failure | Redis cluster unavailable, window state inaccessible. | Fallback: treat every event as first-event (immediate delivery). Temporarily disable aggregation. Resume when Redis recovers. Brief notification burst acceptable. | Degraded mode until Redis recovers |
| Duplicate Events | Same like event processed twice due to Kafka redelivery. | Event deduplication: check event_id in Redis SET before processing. TTL 1 hour. Prevents double-counting in aggregation window. | Zero count inflation |
5 key points to nail a notification aggregation interview