Part 11 · Design It Yourself: Case Studies

Design a Notification System

OTPs that must arrive in seconds, order updates, and a marketing blast to 20 million users in ten minutes — through push, SMS, email and in-app. A full design: API, preferences and templates, priority queues, channel workers, provider fallback, deduplication, rate limits and delivery tracking.

Lesson 6215 min readintermediate#case-study

On this page

  1. The Problem
  2. The Core Idea
  3. How It Works
  4. Trade-offs
  5. In the Real World
  6. Interview Questions
  7. Key Takeaways
  8. Further Reading

The Problem

Almost every app sends notifications:

  • "Your OTP is 482913" (SMS),
  • "Your order has been shipped" (push + email),
  • "Ravi commented on your photo" (push, in-app),
  • "Your monthly statement is ready" (email),
  • "Flash sale starts in 1 hour!" (push to 20 million users).

At first, each team calls the SMS provider or the email API directly from its own code. (A provider is a company that sends the messages for you, like an SMS or email service.) Soon you see problems:

  • users get the same notification twice,
  • OTPs (one-time passwords, like a login code) arrive after they have expired,
  • a big marketing send blocks transactional messages (messages caused by a user action, like an order update),
  • a user who turned off promotional emails still gets them, and complains or reports you as spam,
  • an SMS provider outage means nobody can log in,
  • and nobody can answer "did this user actually receive the message?".

A central notification system solves these problems once, for every team. It is one shared service that all other services use to send messages to users.

The Core Idea

Think about a big post office sorting centre.

  • Anyone in the city can drop off mail (in our system, other services send notification requests).
  • The sorting centre checks each item: Is the address valid? Has this person asked not to get junk mail? Is it urgent?
  • Express mail (OTPs) goes in a fast lane, and bulk flyers (marketing) go in a slow lane. This way flyers never delay urgent letters.
  • Mail goes out through different carriers (post, courier, email, SMS). If one carrier is on strike, the centre switches to another.
  • It keeps tracking records: sent, delivered, opened, bounced (returned because it could not be delivered).

How It Works

Step 1: Requirements

Functional:

  1. Send notifications through several channels: mobile push (a message shown on the phone screen, for iOS and Android), SMS, email and in-app (a notification inbox inside the app, or live messages through a WebSocket, which is a connection that stays open).
  2. Support transactional (OTP, order updates, security alerts) and marketing / bulk notifications (campaigns).
  3. User preferences: the user can opt in or out (say yes or no) for each channel and category, and set quiet hours (times with no messages) and a language.
  4. Templates (message text with blanks that are filled in for each user) with personalisation (Hi {{name}}, your order {{orderId}} has shipped) and localisation.
  5. Scheduling: send at a set time, or at a set time in the user's own time zone.
  6. Tracking: know if a message was sent, delivered, opened, clicked, failed or bounced.

Out of scope: building our own SMS or email delivery systems (we use providers), and the screen for designing campaigns.

Non-functional:

  • Reliability: notifications must not be lost. Aim for at-least-once delivery with deduplication. At-least-once means every message arrives, but it may arrive twice. Deduplication removes the second copy, so users do not get duplicates (post 37).
  • Low latency for critical messages: OTPs and security alerts must arrive within seconds.
  • High throughput for bulk: millions of messages in minutes, without slowing down transactional traffic. (Throughput is how much work is done per second.)
  • Scalable and extensible: it can grow, and it is easy to add a new channel (say, WhatsApp) or a new provider.
  • Respect user choices and the law: opt-outs, consent (permission from the user) and anti-spam rules.

Step 2: Estimation

Assumptions:

  • 50M users, about 10M push, 1M SMS and 5M email notifications on a normal day. (M means million.)
  • Occasionally, a marketing campaign to 20M users within 10 minutes.
~185 / s
normal-day average
16 M notifications a day
~1,000 / s
normal peaks
~33,000 / s
a campaign
20 M users in 10 minutes; this is what drives the design
~3 TB / year
delivery logs
keep detail for ~90 days
The numbers that shape the design. · numbers

So this means:

  • Normal load is modest. Campaign bursts are about 30 times higher. So we need queues (waiting lines for jobs) to soak up bursts, and separate lanes so bursts do not delay OTPs.
  • Third-party providers have rate limits. We must throttle (slow down on purpose) what we send them. A rate limiter is a part that limits how many requests a caller can make in a period of time (post 43).
  • Status logs grow quickly. So use storage split by time (one partition per day or month), and delete old data after a retention period.

Step 3: API and data model

Send API (the request that other internal services call):

HTTP
POST /v1/notificationsIdempotency-Key: order-5521-shipped            ← a retry will not send the message twice{  "userId": "42",  "type": "order_shipped",                     ← points to the template, category and priority  "channels": ["push", "email"],  "data": { "orderId": "5521", "eta": "Thu" },  "priority": "high",  "sendAt": null}202 Accepted  { "notificationId": "ntf_8f2a" }   ← accepted, and will be delivered later (asynchronously)

Bulk sends use a campaign API. It points to an audience segment (a saved group of users) and does not need millions of single calls.

Core data:

Text
users_contact:      user_id, email, phone, locale, timezonedevice_tokens:      user_id, platform (ios/android/web), token, app_version, last_seenpreferences:        user_id, category (marketing/transactional/social…), channel, enabled,                    quiet_hourstemplates:          type, channel, locale, subject/body with placeholders, versionnotifications_log:  notification_id, user_id, type, channel, status, provider_msg_id,                    timestamps (created, sent, delivered, opened), error

Step 4: High-level design

  1. 1
    Order · Auth · Social · Campaign services
    call one Notification API
  2. 2
    Notification API
    validate → idempotency check (is this a repeat?) → add contacts, preferences and template
  3. 3
    Priority queues
    critical (OTP, security) · transactional · bulk; they never share workers
  4. 4
    Channel workers
    push → APNs/FCM · SMS → provider A (fallback B) · email → SES (Amazon Simple Email Service, a service that sends email for you) · in-app → inbox + WebSocket
  5. 5
    Retry queues + DLQ
    short-term failures are retried after a wait; messages that always fail are set aside (DLQ = dead-letter queue)
  6. 6
    Tracking service
    delivered / bounced / opened callbacks → notifications_log
The notification pipeline, end to end. · flow

The flow for "order shipped":

  1. The order service calls the Notification API with an idempotency key. This is a unique ID for the request. If the same key arrives again, the system knows it is a repeat.
  2. The API removes duplicates, loads the user's preferences (email allowed? push enabled?), picks the channels, fills in the templates in the user's language, and applies quiet hours (messages that are not urgent wait until morning).
  3. Messages go into the transactional queue for each channel.
  4. Push workers (programs that take jobs from the queue) send to APNs (Apple Push Notification service) and FCM (Firebase Cloud Messaging, from Google) using the user's device tokens (an address for one app on one phone). Email workers call the email provider.
  5. Providers report the delivery status (in their replies and through webhooks, which are calls that the provider sends to you), and the tracking service updates the log.
  6. Failures are retried with backoff (waiting longer each time), and permanent failures go to a DLQ (dead-letter queue, a place for messages that cannot be processed, post 38).

Step 5: Deep dives

step 1 / 8
Auth service
Notification API
Critical queue
SMS worker
Provider A
Provider B
  1. send OTP · Idempotency-Key login-42-8812
  2. enqueue (priority: critical)
  3. job
  4. send
  5. timeout / 5xx
  6. send (fallback)
    circuit breaker opened on A
  7. accepted · msg id
  8. A delivery receipt later updates notifications_log. OTP jobs older than about 2 minutes are dropped, not sent late
An OTP on the critical lane, with provider fallback. · sequence · step through

Deep dive 1: Priority lanes, so OTPs never wait behind marketing

  • Separate queues or topics per priority (and per channel), each with their own workers. This is a bulkhead: like the walls in a ship, a problem in one part does not flood the others (post 42).
  • Critical: OTPs, password resets, security alerts, payment confirmations. Low latency, highest priority, and watched by message age, which is how long the oldest message has waited ("the oldest OTP must be less than 10 seconds old").
  • Transactional: order and delivery updates.
  • Bulk / marketing: campaigns. Throttled, and it can be delayed or shed (dropped on purpose) when the system is busy (post 44).
  • Provider capacity is shared, so keep part of each provider's rate limit for critical traffic only.

Deep dive 2: Reliability and no duplicates

  • At-least-once delivery through queues (post 37). A worker only sends an acknowledgement (a "done" signal to the queue) after the provider accepts the message. If the worker crashes before that, the queue sends the job again.
  • Remove duplicates at two levels:
    • Request level: the Idempotency-Key from the calling service (post 34).
    • Send level: a (notification_id, channel) record, marked as sent. Workers check it before sending, so a queue message that arrives again does not send twice.
  • Pass an idempotency key to providers if they support it.
  • Transactional outbox in calling services (post 37). An outbox is a database table where a service writes the messages it still has to send. The service saves the notification request in its own database in the same transaction as the order change. So "save order as shipped" and "request a notification" cannot get out of sync.

Deep dive 3: Third-party providers, limits and failover

  • Respect provider rate limits. Use a token bucket per provider (a token bucket is a counter that refills at a steady rate, and each request spends one token; post 43) so the provider does not slow you down or block you.
  • Retry transient errors (errors that may go away: timeouts; 5xx errors, which are replies with a status code from 500 to 599 that mean the server had a problem; and 429 too many requests) with backoff + jitter. Backoff means waiting longer each time. Jitter means adding a random bit to the wait, so retries do not all happen together. Do not retry permanent errors (invalid number, unsubscribed, bad token).
  • Fail over (switch) to a backup provider for SMS and email when the main provider's error rate jumps. A circuit breaker for each provider (post 42) makes this automatic. A circuit breaker stops calls to a service that keeps failing.
  • Handle provider feedback:
    • Email bounces and spam complaints: a hard bounce means the address does not exist. Stop emailing addresses that hard-bounce or complain. If you do not, your sender reputation gets worse, and delivery gets worse for everyone.
    • Invalid push tokens: APNs and FCM tell you when a device token is not valid any more (for example, the app was uninstalled). Delete those tokens.

Deep dive 4: Push notifications specifics

  • Each device registers with APNs or FCM and gets a device token. The app sends the token to your backend, which saves it in device_tokens.
  • Users have several devices (phone, tablet, web). So send to each active token, or only to the most recently used one, depending on the notification.
  • Keep payloads (the data in the message) small, and do not put sensitive data in push text, because it may show on a locked screen. For example, write "You have a new message", not the message itself.
  • Collapse keys / thread IDs stop floods. A collapse key is a label that tells the push service to replace an older waiting message that has the same label with the newer one. They group messages, so the user sees "5 new messages" instead of 5 separate alerts.

Deep dive 5: User experience and respect

  • Preferences and consent: users choose categories and channels, and marketing requires opt-in (the user must say yes first) in many regions. Include an unsubscribe link in marketing emails.
  • Per-user rate limits and digests: do not send 40 "someone liked your post" pushes in an hour. Group them into one digest (a summary message), for example "Ravi and 39 others liked your post".
  • Quiet hours and time zones: send messages that are not urgent at sensible local times.
  • Smart channel selection: if the user is active in the app right now, show an in-app notification instead of a push. If a push is not opened, you may send an email later.
  • Localisation: one template per language, with a default language to use if one is missing.

Deep dive 6: Big campaigns

For 20M messages:

  1. The campaign service works out the audience in batches (for example, 10,000 users per job), and leaves out users who have opted out.
  2. It enqueues batches into the bulk lane.
  3. Workers slow down to stay within provider limits, and spread the sends over the planned time window.
  4. Monitor and pause the campaign if error rates or complaints jump. A kill switch is a control that turns something off at once (post 44).

Deep dive 7: Tracking and observability

  • Status lifecycle: accepted → queued → sent → delivered → opened/clicked, or failed / bounced / suppressed (opted-out).
  • Dashboards: delivery rate per channel and provider, latency (especially OTP end-to-end time), queue ages, DLQ size, bounce and complaint rates (posts 46–48).
  • SLOs (service level objectives, which are reliability targets), for example "99% of OTPs delivered to the provider within 5 seconds" (post 47).

Deep dive 8: Security and abuse

  • Only trusted internal services can call the send API. Each service proves who it is (service authentication, post 49).
  • Protect OTP endpoints from abuse: rate limits per phone number, IP and device. Watch for SMS pumping fraud. Here bots trigger a large number of SMS messages to premium-rate numbers, so that the attackers earn money from the charges (post 43).
  • Do not log full OTPs or sensitive message content (post 51).
  • Check provider webhooks. The provider signs each status callback, and you verify the signature to be sure it is real (post 34).

Wrap-up

  • Key decisions: one central, asynchronous system (it answers "202 Accepted" and sends later) with priority lanes per channel; preferences, templates and dedup applied centrally; channel workers with provider rate limits, retries, circuit breakers and failover; tracking via provider callbacks.
  • Trade-offs: at-least-once plus dedup is reliable, but the dedup storage is complex. Batching and quiet hours give a better experience, but messages that are not urgent arrive later. Several providers make the system resilient, but cost more to connect and pay for.
  • Next steps: choosing the best send time with machine learning, ranking channels, WhatsApp and other messaging channels, and a self-service campaign screen.

Trade-offs

DecisionOption AOption B
Sending modelSynchronous from each service (simple, fragile)Central async system with queues (reliable, scalable)
Delivery guaranteeAt-most-once (no duplicates, may lose)At-least-once + dedup (no loss, more complexity)
LanesOne queue (simple)Priority lanes (OTPs never wait)
ProvidersOne per channel (simple)Primary + fallback (resilient, costlier)
FrequencySend every event (timely, noisy)Digests + per-user limits (respectful, delayed)

In the Real World

Netflix's RENO. Netflix described its Rapid Event Notification System (RENO), which sends events to its apps on many devices (for example, to refresh a viewing list after activity on another device). It uses queues with priorities and a mix of push and pull delivery. It supports many devices per user.

LinkedIn's notification systems. LinkedIn has written about Concourse, a system that creates personal notifications almost in real time. It has also written about a service called Air Traffic Controller. It decides whether, when and through which channel to notify a member, so that people are not flooded with messages.

Slack's "should we send a notification?" flowchart. Slack once shared a famous, very complex flowchart. It shows the many checks that decide whether a notification is sent: is the user active? Is the channel muted? Is do-not-disturb on? Was the user mentioned? What are the preferences? It shows how much logic about preferences and context a good notification system needs.

OTP delivery in banking and payments. Banks and payment apps depend on OTP SMS for logins and payments. They usually use several SMS providers with automatic failover, and watch the delivery time closely, because an OTP that arrives late stops the customer from paying.

Email sender reputation. Email providers such as Gmail and Outlook filter mail by sender reputation (a score of how trustworthy the sender is). Companies that keep emailing addresses that bounce, or ignore spam complaints, see their messages go to the spam folder for all users. That is why suppression lists (lists of addresses you must not email) and bounce handling are essential.

Interview Questions

Q1How do you make sure a critical OTP is not stuck behind a marketing campaign?

Use separate priority queues, with their own workers for each class (critical, transactional, bulk). Give each class its own rate limits, and set alerts on the age of the oldest critical message. Campaigns go through their own throttled lane.

Q2How do you avoid sending duplicate notifications?

Callers send an idempotency key (like order-5521-shipped). The API saves it under a unique constraint, a database rule that rejects the same value twice. Workers are idempotent for each notification and channel. Idempotent means that doing an action twice has the same result as doing it once. Calls to providers reuse the notification ID if the provider supports it.

Q3How do you handle a failing SMS or email provider?

Retry with backoff and jitter for transient errors. Use a circuit breaker for each provider, switch automatically to a second provider, keep a DLQ for permanent failures, and show delivery rates for each provider on dashboards.

Q4How do user preferences and quiet hours fit in?

Check them when the request is enriched, before it goes into a queue. Check the opt-ins for each category and channel, unsubscribes (required by law for marketing), the locale (language and region) for templates, and quiet hours that follow the user's time zone and delay messages that are not critical. Security notifications ignore marketing opt-outs.

Q5How do you send 20 million campaign messages in ten minutes?

Fan out in batches. A campaign job splits the audience into chunks and puts them on the bulk lane. Workers scale horizontally (more machines are added) while the rate limits and quotas of each provider are respected. Progress is tracked for each chunk, so after a crash the job continues instead of starting again.

Q6How do you track whether notifications were delivered?

Store a log row for each notification and channel, with its provider message ID. Then update the status from provider webhooks (delivered, bounced, opened) and in-app read events. This helps support, retries, analytics and removing dead device tokens.

Key Takeaways

  • Build one central, asynchronous notification system instead of every service calling providers directly.
  • Use priority lanes (critical / transactional / bulk) per channel, so OTPs never wait behind campaigns, and monitor message age.
  • Guarantee at-least-once delivery with deduplication (idempotency keys + a record of what was sent), and use the outbox pattern in calling services.
  • Wrap providers with rate limits, retries (transient only), circuit breakers and failover. Handle bounces, complaints and invalid push tokens.
  • Respect users: preferences and consent, quiet hours, digests, per-user limits and localisation. Track every status, protect OTP endpoints from abuse, and never log secrets.

Further Reading

  • System Design Interview by Alex Xu (chapter 10, "Design A Notification System")
  • The Netflix Tech Blog post "Rapid Event Notification System at Netflix"
  • LinkedIn Engineering posts on Concourse and on notification volume control (Air Traffic Controller)
  • Apple's documentation on sending notification requests to APNs, and Firebase Cloud Messaging's architecture overview
  • The Amazon SQS documentation on dead-letter queues, and Amazon SNS documentation on mobile push

Published by Jainik · Last updated · How these lessons are made

Keep learning

Part of the study guide

Related series