← System Design
Classic Case Studies

Design a Notification System

In simple words

A post office for your app: other services drop off letters ("order shipped"), the post office checks each person's preferences (SMS, email, app), queues the letters by urgency, and hands them to the right courier, retrying when a courier fails.

Problem Statement

Design a system that sends notifications to users over push (iOS/Android), SMS, email and in-app channels. Other services (orders, payments, social, marketing) ask it to notify users; it decides the channel, respects user preferences, and delivers reliably at scale without spamming anyone.

Functional Requirements

Non-Functional Requirements

50M/day ~ 600/s average; campaigns burst to ~50K/s for minutes
-> queue-based, horizontally scaled workers; providers have their own rate limits

Core Entities

API Design

POST /v1/notifications
  { "user_id": 42, "category": "order_update", "template": "order_shipped",
    "params": { "order_id": "A123" }, "channels": ["push","email"],
    "priority": "high", "send_at": null }
  Idempotency-Key: order-A123-shipped
  -> 202 { "notification_id": "n_789" }

POST /v1/campaigns { segment_id, template, channels, send_at }  -> 202
GET  /v1/notifications/{id}  -> { status per channel }
PUT  /v1/users/{id}/preferences { ... }

High-Level Design (HLD)

Notification pipelineOther servicesNotification API(dedupe,persist)Priority queuesRouterprefs · templatePush queueSMS queueEmail queueAPNs / FCMSMS providerEmail provider
Separate queues per priority and channel keep a marketing blast from delaying an OTP.
services --> Notification API --> validate, dedupe (idempotency key), persist
                    |
                    v
            [ priority queues ]  high (OTP/transactional)   low (marketing)
                    |
            Router / Preference workers
              - load user prefs, quiet hours, contact info
              - render template per channel & locale
              - per-channel rate limits (don't spam a user)
                    |
      +-------------+-------------+-------------+
      v             v             v             v
 [push queue]  [sms queue]  [email queue]  [in-app queue]
      |             |             |             |
 push workers   sms workers  email workers  in-app store + WebSocket
      |             |             |
    APNs/FCM     Twilio/...   SES/SendGrid   (with provider failover)
      \_____________ delivery status / receipts ____________/
                          -> status DB + analytics

Low-Level Design (LLD)

Data modelnotificationsid PKuser_idcategory, templateidempotency_key UQstatus, send_atdelivery_attemptsid PKnotification_idchannel, providerstatus, errorpreferencesuser_id PKcategory PKchannel PKenableddevicesuser_id, device_idplatformpush_token1 → manycheckedtargets
The idempotency key's unique constraint stops duplicates; attempts record every provider call for retries and status.

Data model

notifications(id PK, user_id, category, template_id, params JSON, priority,
              status, idempotency_key UNIQUE, created_at, send_at)
delivery_attempts(id PK, notification_id, channel, provider, status, error, attempt_no, ts)
preferences(user_id, category, channel, enabled, PRIMARY KEY(user_id, category, channel))
devices(user_id, device_id, platform, push_token, last_seen, PRIMARY KEY(user_id, device_id))

Worker flow (per channel)

msg = queue.receive()
if already_sent(msg.notification_id, channel): ack; return   # dedupe
try:
    provider.send(render(msg))               # with timeout
    record(status="SENT"); ack
except TransientError:
    requeue with exponential backoff (attempt_no + 1)
except PermanentError (bad token / unsubscribed):
    record(status="FAILED"); deactivate token; ack
after N attempts -> dead-letter queue + alert

Follow-up Questions

How do you guarantee an OTP isn't sent twice when the caller retries?
The caller passes an idempotency key; the API stores it with a unique constraint and returns the existing notification on retries. Workers also dedupe by (notification_id, channel) before calling the provider.
A 20M-user campaign is running. How do you keep OTPs fast?
Separate high-priority queues and dedicated workers for transactional traffic, rate-limit campaign expansion, and give providers separate quotas/accounts per traffic class.
Push provider is down for 10 minutes. What happens?
Messages stay in the push queue and retry with backoff; the breaker stops hammering the provider; for critical categories, fall back to SMS after a timeout if the user allows it.

Related