Index/Notification System

SponsorGitHub
Design14 min

Notification System

Events from many producers, matched to recipients, delivered in-app, by email and by push, without losing any.

Primary concepts and the hard part

Concepts
event-driven architecturepub/subfan-outat-least-once + idempotencyretry with backoffdead letter queuesmulti-channel deliverypreference filteringdeduplication/collapsingdigests
The hard part they’re probing
Third-party delivery channels (APNs, FCM, SMTP) fail constantly and are rate limited. How do you guarantee nothing is lost, nothing is duplicated visibly, and one flaky provider doesn't take down the rest?

Requirements

Functionalwhat it must do
Producers (PR service, CI, comments) emit events
Match events to interested recipients (watchers, mentions, participants)
Deliver via in-app inbox, email, mobile push
Per-user, per-type, per-channel preferences; mute threads and repos
Mark read/unread, unread counts
Collapse related notifications ("10 people liked your post")
Email digests instead of per-event mail
Non-functionalhow well it must do it
In-app delivery within seconds; email/push within minutes is fine
Never silently drop a notification
At-least-once delivery with no visible duplicates
Availability over consistency (stale unread count is acceptable)
Out of scopedeliberately left out
ML ranking of notifications
Spam classification
Scale
Events:     5,000/sec avg, 50,000/sec peak (a big repo goes viral, CI storm)
Fan-out:    ~10 recipients avg; "celebrity repos" up to 500k watchers
Deliveries: 50k-500k/sec at peak
Records:    ~0.5 KB → ~2 TB/day
Conclusion
The peak fan-out multiplier is what sizes the fan-out worker fleet, and the 500k-watcher case forces the same hybrid as the feed design.

API / Model

POST
/internal/events
producers only; separate from the user API
202Accepted
GET
/v1/notifications?cursor=&filter=unread
200OKnotification page
POST
/v1/notifications/{id}/read
204No Content
POST
/v1/notifications/read-all
204No Content
PUT
/v1/preferences
{type, channels[], digest_frequency}
200OK
POST
/v1/repos/{id}/mute
204No Content
primary or partition keysort key, ↓ newest firstforeign key → referenced columnnullableHover a table or column to trace its keys

High-level architecture

DatabaseCacheQueue / streamExternal systemFocusClick a node for details

Events pass through two Kafka topics: activity.events feeds the Fan-out service, and delivery.jobs feeds delivery workers that are bulkheaded per provider. The notifications store and the Read API sit beside that pipeline as each user's inbox.

  1. The PR Service, CI Service, Comments and Issues publish to Kafka activity.events, partitioned by entity_id. The Fan-out service consumes it and resolves the audience: watchers, mentions and participants.
  2. It checks whether the event belongs to a celebrity repo with more than 100k watchers. If it does, the service writes nothing per user and marks the event for read-time merge.
  3. For every other event, it filters the audience by mutes and preferences.
  4. It collapses duplicates, so ten likes become one "10 people liked your post" notification.
  5. It makes one batched, idempotent write keyed by user_id and event_id to the notifications store, and puts one job per channel on Kafka delivery.jobs.
  6. The In-app worker, Push worker and Email worker each take their own jobs. The in-app worker sends through Redis pub/sub to the WS gateway, the push worker checks quiet hours before calling APNs / FCM, and the email worker buffers digests before calling the SMTP provider.

Failed provider calls go to the retry stage, which backs off exponentially with jitter behind a circuit breaker per provider. Jobs that run out of attempts land in the dead letter queue, which alerts on depth and is replayed after a fix. On the read side, the Read API pages through the notifications store by cursor, merges celebrity-repo events at read time, and takes badge counts from the Redis unread counters.


Trade-offs and deep dives

01
Why Kafka and not a plain queue

Replay. If the fan-out consumer ships a bug that drops a category of notification, you reset the offset and reprocess. If you add a fifth delivery channel next quarter, it reads history from day one. A queue that deletes on consume gives you none of that.

02
Delivery guarantee framing

Say it precisely: at-least-once delivery with idempotent consumers, producing exactly-once effects. The dedupe key is (user_id, event_id) as the storage primary key for the notification, and (notification_id, channel) in the delivery log so a retried job that already sent an email doesn't send a second one.

03
Check preferences at delivery time, not fan-out time

If a user mutes a thread after the fan-out ran but before the email worker fires, the mute should take effect. Filtering at write time bakes in a stale decision. Small detail, real product consequence.

04
Collapsing / deduplication

Ten likes should be one notification. Implement as a short time-windowed aggregation keyed by (recipient, type, entity): hold for N minutes, merge arrivals, then emit. This is a windowed stream operation, and it also protects the email provider from a burst.

05
Bulkheads per provider

APNs, FCM, and SMTP each get their own worker pool, their own queue, and their own circuit breaker. When SMTP is degraded, push notifications must keep flowing. Sharing a thread pool across providers means one bad provider stalls everything — this is the bulkhead pattern and it's worth naming.

06
Provider rate limits force batching

A CI storm generating 100k email jobs will get you throttled or blocked by your email provider. Two mitigations: batch into digests (one email covering many events) and apply a leaky bucket in front of the provider so output is smooth regardless of input burstiness.

07
Digests are a scheduled drain

Buffer events per user, and a scheduler fires hourly/daily to render and send one email. Requires the user's timezone so "daily at 9am" means their 9am.

08
Quiet hours and caps

Never push at 3am local; cap pushes per user per day. Needs per-user timezone and a counter. Cheap to implement, big product win, and shows you're thinking about the human on the other end.

09
Unread counts

Counting rows on every page load is a range scan per request. Maintain a Redis counter with atomic INCR/DECR, accept that it drifts (missed decrements, race conditions), and run a periodic reconciliation job against the store. Say the drift is acceptable because this is an AP system — the failure mode is a badge showing 3 instead of 2.

10
Celebrity repos

Same hybrid as the feed: above a watcher threshold, don't fan out. Store the event once, and merge it in at read time for users who watch that repo. One cached "recent events for repo X" list serves all 500k watchers.

11
Large fan-out jobs must not block small ones

A 500k-recipient job sitting in a partition starves ordinary notifications behind it. Either chunk large jobs into sub-tasks or route them to a separate low-priority queue. Priority isolation.

12
What to monitor

Kafka consumer lag (the leading indicator), DLQ depth (alert, not dashboard), per-provider delivery success rate and latency, fan-out worker error rate, and end-to-end p99 from event emitted to in-app delivery.


Possible follow-up questions

6 questions·try answering before you reveal