Walkthrough: Notification System
Multi-channel push/email/SMS delivery, provider abstraction, retries with dedupe, and user preference routing.
A notification system is internal infrastructure: other teams hand it a message ("your order shipped", a login code, a marketing campaign) and it delivers via push, email, or SMS. It looks simple and is actually a queue-architecture showcase — priorities, retries, dedupe, and flaky third parties.
Step 1: Requirements
Functional:
- Internal services can send a notification to a user (or a segment of users) on one or more channels.
- Sends can be immediate, scheduled, or bulk campaigns.
- User preferences are respected: channel opt-outs, quiet hours, per-user rate caps.
Non-functional:
- An OTP (one-time passcode) must arrive in seconds — latency matters unequally by type.
- At-least-once delivery, with dedupe: never silently drop a notification, never send the same one twice.
- Scale: steady ~100 sends/sec, but campaign bursts of 5,000/sec — spiky by nature.
Step 2: Core entities
Notification (type, channel, payload, priority, idempotency key), UserPreference (opt-outs, quiet hours, caps), DeliveryAttempt (status per channel per try).
Step 3: API
POST /v1/notifications
{ template_id, user_id | segment_id, channels[],
priority, send_at?, idempotency_key }
One endpoint, internal-facing, called by other services. The idempotency key is supplied by the caller — that decision pays off in the dedupe dive.
Step 4: High-level design
The pipeline: ingestion API → validation → queues → per-channel workers → providers → tracking.
- Ingestion authenticates the calling service and enqueues. It does not talk to providers — accepting and delivering are decoupled so a provider outage never blocks callers.
- Validation checks preferences: opted out of this channel? Inside quiet hours? Over their rate cap? Drop or defer accordingly.
- Queues absorb the spike: a campaign dumps 5,000/sec in, workers drain at their own pace. This is the textbook queue use case — buffering bursty writes.
- Per-channel workers: push workers speak APNs/FCM (Apple/Google push gateways), SMS workers speak a Twilio-style API, email workers an SES-style API. Channels are isolated — separate queues and worker pools — so an email-provider outage does not delay SMS.
- Delivery tracking records every attempt and outcome, for both debugging and the observability probe you should expect.
Deep dive 1: Priorities — the OTP behind a campaign
The classic probe: a marketing campaign enqueues 1M emails, and then a login OTP arrives. In a single queue the OTP waits behind a million promos — a user staring at a login screen for 20 minutes. The fix is structural, not clever: separate queues per priority lane (transactional/OTP vs promotional), with dedicated worker pools for the high-priority lane. A priority field in one queue is not enough — a burst still fills the pipe ahead of the OTP. Separate lanes mean the OTP path is never contended.
Deep dive 2: At-least-once + dedupe
Queues deliver at-least-once (a worker can crash after sending but before acknowledging, so the message is redelivered). Unhandled, that means duplicate texts. The fix is an idempotency check at the send boundary: before calling the provider, the worker does an atomic check-and-set on the notification's idempotency key in a dedupe store (Redis SET NX with a TTL covering the retry window). Already set → someone sent it → skip. This is why the caller supplies the key: retries by the caller dedupe too.
Deep dive 3: Flaky providers
Third-party providers fail routinely. Layer the defenses:
- Retries with exponential backoff and jitter (wait 1s, 2s, 4s... plus randomness so retries don't synchronize into a new stampede).
- A circuit breaker per provider: after N consecutive failures, stop calling for a cooldown instead of hammering a dead API — and optionally fail over to a backup provider (a second SMS vendor) for what matters.
- A dead-letter queue (DLQ — a parking lot for messages that exhausted retries) so nothing vanishes silently; alert on DLQ depth.
Deep dive 4: Campaign fanout
"Send to segment X" where X is 10M users must not happen synchronously in the API call. The ingestion service enqueues one campaign job; fanout workers expand it in batches (query 10k user IDs at a time, enqueue individual sends, checkpoint the cursor). A crash resumes from the checkpoint rather than restarting — and rate-limit the expansion so campaigns can't starve the system. Preference checks run at send time, not enqueue time: a user who opts out mid-campaign stays opted out.
Wrap-up
Recap: one ingestion API decoupled from delivery by queues; priority lanes keep OTPs in seconds; at-least-once plus an atomic dedupe check yields effectively-once sends; provider failures are contained by backoff, circuit breakers, and DLQs; campaigns fan out in resumable batches. With more time: open/click tracking feeding back into rate caps, and localization.