Library
32 min read

Design a notification service

A marketing team schedules a campaign to 40 million users for 9am. At 9:00:02 the queue is full of campaign messages. At 9:00:03 someone's one time password is behind 40 million of them, and by the time it arrives the login screen has already timed out.

A notification is any message your app sends to a person without being asked at that moment: a push alert on the lock screen, an SMS with a login code, an email receipt. A notification service is the one place every team in the company sends them through. Watch one login code go through it.

Sending servicesNotification APITemplatesPreferencesUrgent laneStandard laneBulk laneChannel workersDedupe + throttleDevice registryProviders

Post. The auth service posts a login code for user 4471, marked urgent, with an idempotency key. It gets a 202 back straight away: accepted, not sent yet.

A few words come up all through this page:

  • A provider is the outside company that does the last step of delivery: Apple Push Notification service (APNs) for iPhones, Firebase Cloud Messaging (FCM) for Android, an SMS gateway for texts, an email provider for mail. You cannot see inside them.
  • A campaign is a marketing send to a whole group of users at once, often millions.
  • An idempotency key is a unique label the caller attaches to a request, so that if the same request arrives twice, the second one is recognised and ignored. (Idempotency keys shows one working.)

Nobody wrote a bug in the opening story. Every service worked. The design never said which message mattered more, and that one gap is what this question is about.

Step 1: Understand the problem#

"Send a notification" sounds like one API call. It is really three problems with one name: a fan out problem (one request becomes many messages), a scheduling problem (some messages should wait), and a problem of depending on outside companies that fail often.

You askThey sayWhat it settles
Which channels: push, SMS, email, in app?All four, and a user may get the same message on more than one.One channel worker per channel. A push token, a phone number and a mailbox fail in completely different ways and need retrying on completely different schedules.
How many notifications a day, and how bursty?About a billion, with campaign spikes ten times the average inside a minute.A queue between accepting a message and sending it, so a spike waits in line instead of knocking things over. The spike is also scheduled, not random, so you can plan for it.
Are all notifications equally urgent?No. A one time password is not a discount code.The most important answer on the page. Separate lanes with separate capacity, otherwise a campaign delays a login, which is the failure in the opening story.
Can a user receive the same notification twice?Badly wrong for money and codes, merely annoying for news.An idempotency key from the caller and a dedupe check per user per message. "At least once, plus a dedupe key" is the honest answer, not "exactly once".
Do users control what they receive?Yes. Per category opt outs, quiet hours, and a daily cap.A preference check before anything is queued, and a scheduler that can hold a message until morning instead of dropping it.
What do we owe the sending team afterwards?Whether it was delivered, and whether it was opened.A pipeline for delivery events, and the awkward truth that "sent" is the only thing you control. Providers tell you the rest, late and not always.

What you are building, and what you cut#

Functional requirements
What the system does.
  • Accept a notification request from any internal service. One API, one contract, so no team writes its own APNs client.
  • Fan out to the channels a user has turned on. Push, SMS, email and in app, cheapest first.
  • Respect preferences, quiet hours and caps. The cheapest notification is the one you decided not to send.
  • Retry failures and report delivery. Providers fail all the time, and each in its own way.
Non-functional requirements
How well it has to do it. The numbers you commit to.
  • A billion notifications a day, with 10x campaign spikes.
  • An urgent notification leaves the system in under 5 seconds for 99 out of 100 sends (p99), always.
  • No duplicate for a given idempotency key inside 24 hours.
  • A provider outage delays notifications, it never loses them.
Out of scope
What you cut, and say so out loud.
  • Writing the copy and choosing who to target. That is a campaign tool that calls this service.
  • The in app inbox and read state. Similar words, different system: it is a feed problem.
  • The last mile of a push. APNs and FCM own it and you cannot see inside it.
  • Ranking or bundling with machine learning. Name it as a layer above the daily cap.

Back of the envelope#

Three inputs. Watch the gap between the average and the peak, because the whole design is about that gap. (New to these estimates? Back of the envelope walks through the method.)

Send rate and the spike
500 million
2
10x
Notifications a day500M x 2 = 1.0 billion
Average send rate1,000,000,000 / 86,400 = 11,574 per second
Peak send rate11,574 x 10 = 115,741 per second
Device tokens stored500M x 2.4 devices = 144 GB
Urgent share of trafficabout 2%
115,741 per second
at peak, and 98% of it could wait an hour without anybody noticing

The gap between 11,574 and 115,741 is the whole capacity argument. You do not build for the bulk peak. You build a lane that lets the 2% that matters overtake it. Sizing everything for the campaign spike costs several times more and still does not fix which message goes first.

The number people get wrong

Candidates size the fleet for the peak send rate and stop. The real limit is the provider. APNs takes a lot of traffic, most SMS gateways do not, and an email provider has a per second cap written into your contract. A sender fleet twice as fast as the provider allows buys you nothing except a longer queue.

Step 2: Propose the high level design#

The core entities
  • NotificationOne message to one person, made from a template name, its parameters and an idempotency key.acceptedsuppressedqueuedsentdeliveredfailed
  • LaneUrgent, standard or bulk. A separate topic with its own consumers.
  • PreferenceWhat a user allows on each channel, plus quiet hours and the daily cap.
  • ProviderAPNs, FCM, the SMS gateway or the email provider, each behind its own circuit breaker.
Never breaks: A login code is never stuck behind a campaign.

The API#

One endpoint does almost all the work. The interesting decisions are in the request body. Note the 202 Accepted replies: an HTTP status that means "I have your request, I have not finished it". The caller does not wait for the send. The work waits in a queue instead, which is what message queues are for.

POST/v1/notifications
{
  "userId": 4471,
  "template": "otp_login",
  "params": { "code": "402913" },
  "priority": "urgent",
  "idempotencyKey": "otp:4471:1756112400"
}
returns 202 { "notificationId": "n_8f21", "status": "accepted" }
Why: 202, not 200, because nothing has been sent yet, and pretending otherwise makes every caller write a bug. The caller sends a template name and values, not finished text, so translations, copy fixes and per channel formatting all stay inside this service.
POST/v1/notifications:batch
{
  "template": "weekly_digest",
  "segmentId": "seg_9931",
  "priority": "bulk",
  "notBefore": "2026-09-11T09:00:00+05:30"
}
returns 202 { "campaignId": "c_1180", "estimated": 41200000 }
Why: A campaign is one request, not 40 million. Expanding the user group (the segment) inside the service means it happens at a rate you control, and cancelling a campaign that is going wrong is one call, not a support incident.
PUT/v1/users/{id}/preferences
{
  "marketing": { "push": false, "email": true },
  "quietHours": { "start": "22:00", "end": "08:00", "tz": "Asia/Kolkata" },
  "dailyCap": 5
}
returns 200
Why: Preferences live here, not in each team's database. The moment a second team keeps its own opt out list, someone who unsubscribed gets a message and you are explaining it to a regulator.
GET/v1/notifications/{id}
returns 200 { status: "delivered", channel: "push", attempts: 2, deliveredAt: "..." }
Why: Sending teams will ask "did it arrive?". Answer them, or they will build their own tracking, badly. Status can stay "sent" for ever, because some providers never confirm anything.

The data model#

Three tables, and the interesting one is the smallest. A wide column store (think Cassandra) suits this: huge write volume, and every read is "everything for one user".

notificationswide column store, partitioned by user_id
user_idbigintPKPartition key: all of one person's rows sit together, so counting today's sends for the daily cap is one cheap read.
notification_iduuidPKSorted by time inside the partition, so a user's history is one scan.
idempotency_keyvarchar(128)UQUnique per user for 24 hours. This one rule is the whole defence against duplicates.
templatevarchar(64)The template name, not the finished text. Storing finished text means a typo fix cannot reach old messages, and it multiplies storage.
priorityenumIDXurgent, standard or bulk. It chooses the lane, and nothing else needs to know it.
stateenumaccepted, suppressed, queued, sent, delivered, failed. Suppressed (we chose not to send) is a real outcome and gets counted.
attemptsintHow many tries. Past the limit it goes to the dead letter queue with the last provider error attached.
Sample row
4471 | n_8f21... | otp:4471:1756112400 | otp_login | urgent | delivered | 1
Device tokens get their own table keyed by user, because they change constantly and are read on every send. Preferences get a third, cached hard, because they are read on every send and written almost never.

The whole system on one whiteboard#

Figure 1 is the picture at the top with every box added, including the ones the login code skipped past.

Figure 1. The three amber boxes are where notifications get stopped: preferences say no, the throttle says not this many, the scheduler says not right now. Whatever survives all three is worth paying a provider for.

Walking Figure 1:

  1. Any internal service posts a notification and gets a 202 at once. Nothing downstream is allowed to make a checkout page slow.
  2. The API renders the template for each channel: a message with blanks, like Your code is {code}, filled in with the values. Fix a typo once and it is fixed everywhere, in every language.
  3. Preferences are checked before the message is queued. A message we decide not to send is recorded as suppressed, not silently dropped, because "why didn't my user get it?" is the most common support question you will get.
  4. What survives goes into the lane for its priority. A lane is its own queue with its own workers, three of each, so nothing in the bulk lane can delay the urgent one. If queues are new, message queues covers them from the start.
  5. Dedupe (drop a repeat) and the daily cap run just before sending, not when the message is accepted, because the cap is about what a person actually received.
  6. Channel workers pull messages at the speed the provider will take, not the speed the queue offers. Workers pulling, rather than the queue pushing, is what lets them set the pace (push vs pull).
  7. Workers call the providers, record whatever delivery callbacks come back, and move permanent failures to a dead letter queue: a side queue where messages that keep failing are parked, with the error, for a person to look at.
Your answer

Step 4 splits into three lanes. If you had only one queue and doubled the number of workers instead, what exactly would still be broken?

Step 3: Design deep dive#

Priority is a lane, not a number#

Picture a single post office counter with 40 million parcels in line. A customer arrives with one urgent letter. Labelling the letter "urgent" does not help if the clerk still has to dig through the line to find it. What helps is a second counter that only takes urgent letters. That is the whole idea.

How do you make sure a login code is never stuck behind a campaign?

  1. First try
    One queue, first in first out
    Everything goes into one queue and workers take from the front. Simple, and fine while traffic is small.
    Where it breaks: A campaign adds 40 million messages at 9:00. A login code added at 9:00:03 is behind all of them, and arrives after the login screen has timed out.
  2. Better
    One queue with a priority field
    Tag every message urgent, standard or bulk, and have the queue hand out urgent ones first.
    Where it breaks: To hand out the most urgent message, the queue has to look through what it holds. At 40 million messages that is not something it does in a few milliseconds, so the login code still waits behind the work of finding it.
  3. What you ship
    Separate lanes with their own workers
    Three topics (named queues), three groups of workers, three separate capacity budgets. The urgent lane is sized for its own small peak and is nearly always empty. The bulk lane is sized for cost and is expected to lag. A login code never shares a line with a discount code.

Figure 2 plays that out: a login code and a campaign arriving at the same moment.

1/6 The campaign arrives first, as one request. It has not become 40 million messages yet, and where that expansion happens is a real design decision.
Figure 2. One OTP and one campaign entering at the same moment. The lanes never touch, so the OTP overtakes 40 million messages without anything having to be sorted.
The follow up you will get

"What if urgent traffic itself spikes?" The honest answer: urgent traffic comes from real human events, logins and payments, which do not jump tenfold in a minute. If a team starts marking marketing messages urgent, that is not a capacity problem, it is a rules problem. Fix the priority per template and per caller, and put each team's urgent volume on a dashboard someone reviews.

Delivering at least once without annoying anybody#

Every provider can take your request, fail to answer, and deliver anyway. So you must retry, and a retry can create a duplicate. That rules out exactly once delivery. What you can build is at least once delivery (every message arrives, sometimes twice) plus a way to spot and drop the repeats. At most once vs at least once vs exactly once explains why exactly once cannot exist across a network.

A provider timed out. Was the message delivered, and what do you do?

  1. First try
    Send once, never retry
    If the provider errors or times out, mark the message failed and move on. No duplicates, ever.
    Where it breaks: Providers time out all the time. A good share of login codes simply never arrive, and nobody finds out until users complain.
  2. Better
    Retry every failure
    On an error or timeout, send it again until the provider says yes.
    Where it breaks: A timeout does not mean "not delivered". The first attempt often got through, so the user gets two codes. Two different code paths queuing the same message makes it worse.
  3. What you ship
    Retry, and recognise repeats in three places
    The caller sends an idempotency key, enforced as unique per user for 24 hours in the database. The worker checks a dedupe set just before sending, which catches the same message queued twice by two code paths. And where the provider has its own token, use it: APNs has a collapse id, while most SMS gateways and email have nothing, so this last layer is partial. Duplicates become rare, not impossible.

For a login code the key writes itself: otp:{userId}:{minute}, so a retried login cannot produce a second message. Keep the key in the database, not in a cache, because a cache can evict it, and an eviction should never cost a duplicate. For a payment confirmation, build the key from the payment id, so the same payment can never notify twice.

The retries themselves need care too. Hammering a provider that is already struggling is how a slow provider becomes a dead one.

How should a worker retry a failing provider?

  1. First try
    Retry at once, in a loop
    The send failed, so send it again straight away.
    Where it breaks: When APNs slows down, every worker retries instantly, multiplying the load on the provider at the worst moment. To APNs your retries look like an attack, and it rate limits you.
  2. Better
    Exponential backoff with jitter
    Wait 1 second, then 2, then 4, doubling each time, and add a small random amount (jitter) so thousands of workers do not all retry on the same tick. Stop after a few attempts and park the message in the dead letter queue.
    Where it breaks: Each worker still blocks on every attempt. If the provider is fully down, every worker spends its time waiting on timeouts, and all three lanes stop moving.
  3. What you ship
    Backoff, jitter and a circuit breaker per provider
    A circuit breaker counts failures to one provider. After a handful it "opens": workers stop calling that provider for a while and leave its messages in the queue. Every so often it lets one request through to test, and closes again when the provider recovers. One provider failing now slows one channel, not everything.

Retries, backoff and circuit breakers has a live retry storm if you want to watch the second rung fail.

The cap that stops people uninstalling the app#

The most expensive failure here is not a dropped message. It is sending so many that someone turns notifications off, because that person is now unreachable for ever, including for the messages that matter. Think of a shop that phones you three times a day: you block the number, and then you miss the call about your order.

Who decides how many notifications one person gets?

  1. First try
    Each team limits itself
    Marketing promises to send at most two a day. Orders sends what it needs. Social sends a few.
    Where it breaks: Every team believes its own message is the important one, and nobody sees the total. A user gets fifteen in a day from six polite teams and switches everything off.
  2. Better
    A daily cap in the platform
    The notification service counts what each person received today, per category, and stops at the cap. The count lives in the same partition as the user's notifications, so the check is one read.
    Where it breaks: A cap alone drops messages at 11pm and wakes people at 6am, and it treats ten likes on one photo as ten separate messages.
  3. What you ship
    Cap, quiet hours and collapsing
    Quiet hours in the person's own timezone: a bulk message is held until morning, not dropped, and an urgent one still goes. And a collapse rule: five notifications of the same kind inside an hour become one that says five. Collapsing is the only rule here that reduces volume instead of just moving it.

Say out loud that this belongs in the platform. A limit that each team implements for itself is not a limit. (The same thinking, applied to requests rather than messages, is in rate limiting vs throttling vs backpressure.)

Break it#

Raise the pressure and watch which box goes red first.

Send pressure
12k/s
12k/s120k/s bulkprovider throttlesprovider downrepaired
Healthy. An average day. Every lane is near empty, workers are half idle, and the providers do not notice you.
Read every step as text
  1. 12k/s · Healthy. An average day. Every lane is near empty, workers are half idle, and the providers do not notice you.
  2. 120k/s bulk · Degraded. A campaign lands. The bulk lane builds a backlog of several million and drains over twenty minutes. That is fine: the backlog is the shock absorber doing its job, and the urgent lane is still empty.
  3. provider throttles · Degraded. The SMS gateway starts answering 429, "too many requests". Workers back off, the queue grows faster than it drains, and SMS delivery goes from seconds to minutes. Correct behaviour. Alert on queue age (how long the oldest message has waited), not queue depth.
  4. provider down · Down. APNs is unreachable. With no circuit breaker, every worker is stuck waiting on a timeout, all three lanes stop draining, and your vendor's outage has become yours. This is the failure that actually happens.
  5. repaired · Healthy. The breaker opens after a handful of failures. Push messages wait in the queue, workers move on to other channels, and a login code that can go by SMS still arrives. The provider is still down. The system is not.

The lesson from the last two states: in a system whose whole job is calling other people's services, the circuit breaker is not a nice extra. It is the difference between one channel slowing down and everything stopping.

Trade-offs#

ChoiceWhat you gainWhat you payPick it when
Separate queues per priorityUrgent messages are never behind bulk ones, with no sorting and no preemption.Three sets of consumers to run and monitor, and someone has to police who gets to say urgent.Always, the moment two classes of notification share a system. This is the cheapest correctness you will buy all day.
At least once with a dedupe keySurvives every provider timeout and every worker crash without losing messages.A duplicate is possible in the window where a provider delivered but never told you.Every time, because exactly once across a third party API does not exist and pretending it does hides the retry bug.
Expanding a campaign inside the serviceOne request instead of 40 million, expansion at a rate you control, and cancellation is one call.The service now owns segment expansion, which is state and work that feels like somebody else's job.Any campaign above about a hundred thousand recipients. Below that, let the caller loop.
Preferences and caps in the platformOne opt out that actually works, and a limit no individual team can talk its way around.A hot read on every send, and a service that has to be up for anything to be sent at all.Always. The regulatory version of this argument ends the discussion faster than the engineering one.

The level bar#

One moment from this round, answered three ways. Pick the one you think is staff level before the labels appear. (What changes at senior and staff explains the gap.)

Pick the staff level answer

InterviewerA campaign team wants 40 million notifications sent at 9am sharp. How do you handle it?

Show which is which without guessing
  • Answer A: Staff. Gets the mechanics right, then questions a requirement that cannot be met as written.
  • Answer B: Mid level. The providers start answering 429 within seconds, and nothing protects the login codes queued behind the campaign.
  • Answer C: Senior. Uses the lanes and respects the providers. Takes "9am sharp" at face value.

Then run the question cold, with a 45 minute timer and no notes, and score the run on the five things this question is really testing.

Score your last run
0 / 10
  1. The 202
  2. Priority
  3. Duplicates
  4. A provider fails
  5. The person receiving it
Pick 0, 1 or 2 on every row. Be the strict interviewer, not the kind friend.

Interview replay#

Interviewer
A service wants to notify a user. Take me through what happens.
Opening move. They want to see whether you make the caller wait.
You
The caller posts a template name, parameters and an idempotency key, and gets a 202 straight away. We render, check preferences, and enqueue onto the lane for that priority. Workers pull from the lane, check a dedupe set, look up device tokens and call the provider. Nothing in that path blocks the caller, because the caller is usually a checkout or a login and cannot afford to wait for APNs.
Names the 202 and the reason for it in the first answer. That is the difference between having used a notification service and having drawn one.
Interviewer
A campaign to 40 million users is in the queue and a one time password arrives. What happens?
The real question. Everything before was warm up.
You
They are in different queues, so the OTP is not behind anything. Sorting one queue by priority does not work at that depth, since you would have to read the backlog to sort it. Separate topics with separate consumer groups means the urgent lane is nearly always empty and its latency is independent of how much bulk traffic exists.
Rejects the obvious answer and explains why, rather than just asserting the better one.
Interviewer
Can you guarantee a user never gets the same notification twice?
A trap. The confident yes is wrong.
You
No, and I would not claim it. A provider can accept a request, time out on us, and deliver anyway. What I can do is at least once delivery with an idempotency key unique per user for 24 hours, enforced in the database, plus a dedupe check right before sending. That makes duplicates rare rather than impossible, and for the cases where a duplicate is genuinely unacceptable, like a payment confirmation, I would make the key deterministic from the payment id.
Refusing to overclaim, then giving the mechanism that gets closest. Much stronger than a yes.
Interviewer
APNs starts timing out. What does your system do?
Checking whether you have thought about being a client of something unreliable.
You
A circuit breaker per provider opens after a few failures, so workers stop blocking on socket timeouts and the push lane parks in the queue instead. Retries use exponential backoff with jitter and a cap, then the dead letter queue. The thing I would watch is queue age rather than queue depth, because depth looks alarming during a normal campaign and age tells you whether anything is actually stuck.
The queue age point is the one that sounds like operating experience, because it is.
Interviewer
How would you decide the daily cap per user?
Open ended. They are checking for an invented number.
You
I would not pick it analytically. I would measure the relationship between notifications received in a week and the rate at which people disable notifications entirely, then set the cap below wherever that curve turns. I have not run that experiment, so I would want the data before committing to a number, and I would expect it to differ a lot by category and by market.
Names the metric that actually matters, which is opt out rate rather than open rate, and admits the limit of what they know.

Checkpoint#

Checkpoint

1. Why does a single queue with a priority field fail at 40 million messages?

2. A provider accepts a send, then times out before responding. What is the correct behaviour?

3. Same system, but now the product wants notifications ranked and bundled by relevance. What changes first?

Worth memorising
  • A billion a day is 12,000 a second average and 120,000 in a campaign minute. Build for the gap, not the peak.
  • Urgent traffic is about 2% of the total. That 2% is what the separate lane exists to protect.
  • Three lanes, three consumer groups. Sorting one queue that is 40 million deep is not a thing you do in milliseconds.
  • At least once, with a 24 hour dedupe key. Exactly once across a third party API does not exist.
Say this in 60 seconds

A notification service is mostly a fan out and a scheduling problem, plus being a well behaved client of providers you do not control. Internal services post a template name, parameters and an idempotency key and get a 202 immediately, because nothing downstream may block a login or a checkout. We render, check preferences and quiet hours, and enqueue onto one of three lanes by priority. The lanes are separate topics with separate consumers, which is what lets a one time password overtake a 40 million message campaign without anything being sorted. Workers pull at a rate the provider tolerates, dedupe just before sending, and retry with backoff behind a circuit breaker per provider, because the failure that actually happens is a vendor going slow and taking every worker with it. Delivery is at least once with a dedupe window, since exactly once across a third party API does not exist. And the platform, not the sending teams, owns the daily cap, because the most expensive failure here is somebody turning notifications off entirely.