Design a Notification System
medium"Your order has shipped" needs to reach a user. It might go as a push notification, an email, an SMS, or a message inside the app — often several at once.
Key Concepts
High-level design
Producers → NotificationRequest API.
Notification service → preference check, rate limit, dedup, template render.
Kafka topics per channel + priority (push-tx, push-bulk, email-tx, email-bulk, ...).
Per-channel worker pools → vendor API call with retries.
Vendor webhooks → status update → audit store.
Bounce handler → update suppression list.
Analytics → opens, clicks, conversion.
Components
- Notification API gateway.
- Preference service (per-user channel preferences).
- Suppression list (bounces, opt-outs).
- Rate limiter (per user, per template, per recipient).
- Template renderer (with localization, A/B variants).
- Channel workers (push, email, sms, in-app, slack).
- Vendor abstraction layer (multi-vendor with health-based routing).
- Audit + analytics store (Cassandra or ClickHouse).
- Webhook receiver for vendor status updates.
- Bounce / unsub processor.
Channel characteristics
Channel Latency Cost Failure mode
------------------------------------------------------------------
Push ~1 s free token expires silently;
delivery decays unnoticed
SMS 1-10 s per message carrier filtering, silent
drops, per-country rules
Email seconds ~free spam foldering; hard vs soft
bounce handling is mandatory
In-app instant free only seen when the app is open Retry policy per channel
Push 3 attempts, 1s / 10s / 60s; drop on invalid token
SMS 2 attempts, 30s apart; stop on permanent carrier error
(retrying costs money and annoys the user)
Email 5 attempts over hours for SOFT bounces; zero retries
for hard bounces, and suppress the address permanently
In-app write once to the store; it is a database row Cost check that changes designs
1M users x 1 SMS at 0.15 INR = 150,000 INR
Same notification by push = ~0
-> the preference default, and the fallback ordering, are a
finance decision as much as a UX one.Delivery pipeline
1. Event ingested (order.shipped) -> durable queue.
2. Expand to recipients (one user, or a group).
3. Apply preferences, in this order:
a. Hard suppression (unsubscribed, hard-bounced, invalid)
b. Category preferences (marketing off, transactional on)
c. Channel preferences (push yes, SMS no)
d. Quiet hours -> defer to a scheduled send, or digest
e. Rate limit per user (collapse or drop)
Security and legally required messages skip (b) and (d), never
(a).
4. Idempotency check: key = event_id + channel + recipient.
Already sent -> stop.
5. Render per channel (template + locale + timezone).
6. Dispatch to the provider adapter, with per-channel retry.
7. Record outcome; feed bounces and invalid tokens back into (a). Why the order matters
Applying rate limits before suppression wastes budget on messages
that will be dropped anyway; applying quiet hours before the
security exemption delays password resets until morning.Failure modes in production
Duplicate sends after a queue redelivery
Symptom: users receive the same message twice, hours apart.
Cause: at-least-once delivery with no idempotency record.
Fix: deterministic key checked and written before dispatch, with
a TTL longer than the maximum retry window.
Silent push decay
Symptom: delivery rate drifts from 95% to 60% over months.
Cause: expired tokens never pruned.
Fix: treat the provider's invalid-token response as authoritative
and delete immediately; audit token age. Hard bounces retried
Symptom: sender reputation falls; unrelated email starts landing
in spam.
Cause: retry policy applied uniformly to all failures.
Fix: distinguish hard from soft bounces; suppress hard bounces
permanently and alert on the rate. Notification storm from a batch job
Symptom: a backfill emits 200 events per user; phones buzz for
minutes.
Cause: no per-user rate limit or collapsing.
Fix: cap per user per hour, collapse related events into one
summary, and make batch jobs mark their events as digestible.