← Problems

Notification System

mediummid / senior
Start timed practice

Send push, SMS and email notifications from many internal services reliably, with priorities, rate limits and user preferences.

Requirements

Functional

  • Internal services (orders, security, marketing) submit a notification for a user; the system delivers it over push (iOS/Android), SMS and email.
  • Users set preferences: which channels, which categories, quiet hours, and a global opt-out for marketing.
  • Priorities: transactional (OTP, fraud alert) must go out in seconds; marketing can take minutes and be batched.
  • Templates with per-locale content; the caller sends a template id and variables, not final text.
  • Delivery status is tracked (queued, sent, delivered, failed, bounced) and queryable per notification.

Non-functional

  • No transactional notification is ever lost: at-least-once delivery end to end.
  • No user receives the same notification twice because of a retry.
  • A slow or failing provider for one channel must not delay other channels.
  • Respect provider rate limits (e.g. SMS provider allows 1,000/s) and legal rules (no marketing SMS at 3 am).
  • p99 time from submit to provider hand-off < 5 s for transactional priority.

Out of scope

  • In-app notification inbox UI, A/B testing of content, campaign targeting and audience selection.

Back-of-envelope estimates

Assumptions

  • 50M users. Each gets ~5 notifications/day on average: 250M notifications/day.
  • Split: 60% push, 30% email, 10% SMS.
  • Marketing blasts are the peak: a campaign to 20M users in 10 minutes.

Throughput

  • Average: 250M / 86,400 s ≈ 2,900/s.
  • Campaign peak: 20M / 600 s ≈ 33K/s submitted. Queues absorb this; senders drain at provider rate limits (push providers accept tens of thousands/s; SMS maybe 1K/s so a 2M-recipient SMS blast takes ~35 minutes and that is fine).

Storage

  • Notification record (id, user, template, status, timestamps) ≈ 300 B. 250M/day × 300 B ≈ 75 GB/day. Keep 30 days hot ≈ 2.3 TB, then archive to object storage. Partition by day for cheap drops.
  • Dedup keys: 250M/day × ~60 B ≈ 15 GB/day in Redis with a 24-hour TTL. Fine on a small cluster.
  • Device tokens: 50M users × 2 devices × 200 B ≈ 20 GB. Small.

Cost sanity check

  • SMS at ~$0.01: 25M/day × $0.01 = $250K/day. SMS volume is the number to challenge in the interview; push and email are near free. This is why preferences and channel fallback matter.

API design

POST /v1/notifications

  • Body: { idempotencyKey, userId, templateId, variables, priority: "transactional" | "marketing", channels?: ["push","sms","email"], scheduleAt?, ttlSeconds? }
  • Returns 202 { notificationId, status: "queued" }. Returns the same notificationId if idempotencyKey was already seen.
  • Errors: 400 unknown template or missing variables, 404 unknown user, 429 caller over its submission quota.

GET /v1/notifications/{id}

  • Returns { status, perChannel: [{ channel, status, providerMessageId, attempts, lastError, updatedAt }] }.

PUT /v1/users/{userId}/preferences

  • Body: { channels: { push: true, sms: false, email: true }, categories: { marketing: false, security: true }, quietHours: { start: "22:00", end: "08:00", tz: "Europe/London" } }

Provider webhooks

  • POST /v1/webhooks/{provider}: delivery receipts, bounces, unsubscribes. Must be idempotent and verified by signature.

Notes

  • 202 not 201: the call returns before anything is sent. Callers poll or subscribe to status events.
  • ttlSeconds lets a caller say "an OTP older than 5 minutes is useless, drop it instead of retrying".

Data model

Entity: notification

  • notification_id (PK, ULID so it sorts by time), user_id, template_id, priority, category, variables (JSON), status, created_at, schedule_at, expires_at.

Entity: delivery (one per channel attempt stream)

  • delivery_id (PK), notification_id (FK, indexed), channel, provider, provider_message_id (indexed for webhooks), status, attempts, last_error, updated_at.

Entity: user_preference

  • user_id (PK), channel flags, category flags, quiet_hours, timezone, updated_at.

Entity: device_token

  • user_id, platform, token (PK), last_seen, valid.

Entity: template

  • template_id, locale, channel, subject, body, version. Immutable per version.

Dedup state

  • Redis SET idempotency_key → notification_id with a 24-hour TTL. Checked before anything is enqueued.

Storage choice

  • notification and delivery: a relational DB partitioned by day, or Cassandra keyed by notification_id. Access is by id and by user + time; both fit either store. Relational is easier for the status API and ops queries.
  • Preferences and device tokens: relational, small, read on every send; cache in Redis.
  • Templates: relational with a CDN-style in-memory cache in each worker.

High-level design

Submit path
1. A producer service calls POST /v1/notifications on the notification API.
2. The API validates the template and variables, checks the idempotency key in Redis, writes a notification row with status queued, and publishes a message to the intake queue. It returns 202.

Routing path
1. A router consumer reads from intake, loads user preferences and device tokens (cached), applies rules: channel opt-outs, category opt-outs, quiet hours (defer marketing until the window ends), de-duplication across channels, channel fallback (no valid push token → email).
2. It renders the template per channel and locale, writes one delivery row per channel, and publishes to the per-channel, per-priority queue: push-transactional, push-marketing, sms-transactional, and so on.

Send path
1. Channel workers consume their queue at a rate capped to the provider's limit, call the provider (APNs, FCM, Twilio, SES), and update the delivery row with the provider message id.
2. On failure: retry with backoff by re-publishing with a delay; after N attempts or past expires_at, move to a dead-letter queue and mark failed.

Feedback path

  • Providers call webhooks with delivered, bounced or unsubscribed. The webhook handler updates delivery status, invalidates dead device tokens, and flips preferences on unsubscribe.

Why separate intake, routing and sending?

  • Intake must be fast and never block on providers. Routing holds the business rules in one place. Senders are per-provider so a Twilio incident cannot back up push.

Deep dives

1. Exactly-once from the user's point of view: idempotency and dedup

  • Producer-side: the caller supplies idempotencyKey (e.g. "order-shipped:order123"). The API does SET NX in Redis; if the key exists, return the existing notification id. This stops producer retries creating duplicates.
  • Consumer-side: queues are at-least-once, so a worker can crash after calling Twilio but before acking. Before each provider call, the worker does SET NX on "sent:{delivery_id}" with a TTL; if it already exists, skip the call and ack. There is still a tiny window between the provider call and the SET; accept it or use provider-side idempotency keys where supported (SES and Twilio both accept one).
  • Order matters: write the dedup marker, then call the provider, then update the row. Explain why each order is wrong if swapped.

2. Retries, backoff and the dead-letter queue

  • Classify errors: 4xx from the provider (bad token, invalid number) is permanent → fail immediately and fix the data (invalidate the token). 5xx and timeouts are transient → retry.
  • Exponential backoff with jitter: delay = min(cap, base × 2^attempt) × random(0.5, 1.5). Base 2 s, cap 10 minutes, max 6 attempts for transactional. Jitter prevents synchronised retry storms after a provider outage.
  • Implement delays with a delay queue (SQS delay, RabbitMQ TTL + dead-letter exchange, or a Redis sorted set keyed by due time) rather than sleeping in the worker.
  • After max attempts or past expires_at: publish to a DLQ with the full payload and error history. Alert on DLQ depth. Provide a replay tool that re-enqueues DLQ messages after a provider recovers.

3. Queue topology, priorities and provider rate limits

  • One queue per channel × priority: 3 channels × 2 priorities = 6 queues. Transactional workers are always scaled; marketing workers scale on queue depth.
  • Isolation: a marketing SMS blast fills sms-marketing; sms-transactional stays empty and OTPs go out in seconds.
  • Provider rate limits: each worker pool holds a shared token bucket (in Redis) sized to the provider limit, e.g. 1,000/s for SMS. Workers take a token before each call; when the provider returns 429, back off that pool for the Retry-After period and shrink the bucket.
  • Provider failover: if the primary SMS provider errors above a threshold, a circuit breaker routes to a secondary provider. Keep provider_message_id namespaced by provider so webhooks still match.

4. Preferences, quiet hours and aggregation

  • Rules are evaluated once in the router, never in senders. Transactional and security notifications ignore marketing opt-out and quiet hours; marketing is deferred to the end of the quiet window in the user's timezone (publish with a delay, or store with schedule_at and let a scheduler enqueue it).
  • Aggregation: "Alice and 12 others liked your post" is produced by holding low-priority social notifications in a per-user buffer (Redis list with a 10-minute window) and flushing one summary. The buffer key is the user plus category.
  • Frequency caps: at most N marketing pushes per day per user, enforced with a daily counter in Redis.

5. Templates and localisation

  • Templates are versioned and immutable; a notification records the template version it rendered, so a later edit does not change history.
  • Rendering uses the user's locale with fallback (fr-CA → fr → en). Variables are validated against the template's declared schema at submit time so a missing variable fails fast with a 400 rather than sending a broken message.
  • Rendering happens in the router, once per channel, so senders are dumb and fast.

6. Observability and delivery status

  • Every state transition writes the delivery row and emits an event to a status topic. Dashboards show per-channel and per-provider sent, delivered, failed rates and p99 submit-to-sent latency.
  • Webhooks are the source of truth for delivered; without them, "sent" means "accepted by the provider". Expose both so callers do not confuse them.
  • Trace id from the producer flows through queue message attributes so a single notification can be followed end to end.

Trade-offs & wrap-up

Summary of decisions

  • At-least-once queues everywhere, with idempotency keys at submit and dedup markers before each provider call.
  • Three stages, intake → router → per-channel senders, so business rules live in one place and providers are isolated.
  • Per channel × priority queues with provider-sized token buckets and circuit breakers.
  • Exponential backoff with jitter, bounded attempts, TTLs for time-sensitive content, and a DLQ with replay.
  • Templates versioned and rendered once in the router.

What was given up

  • Strict exactly-once: a very small duplicate window remains between the provider call and the dedup marker.
  • Latency for marketing: it is deliberately deferred and batched.
  • Operational surface: six queues, several worker pools and webhook handlers to run.

With more time

  • Provider cost routing (cheapest SMS route per country), in-app inbox as a fourth channel, campaign-level throttling, and a per-user sending history that lets product teams reason about notification fatigue.