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.
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.
40 million campaign messages, then:login codeby the time it arrives, the login screen has timed out
"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 ask
They say
What 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.
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.
your sender fleet, as fast as you likeprovider capa faster fleet only buys a longer queue
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.
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.
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.
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.
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.
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_id
bigint
PK
Partition key: all of one person's rows sit together, so counting today's sends for the daily cap is one cheap read.
notification_id
uuid
PK
Sorted by time inside the partition, so a user's history is one scan.
idempotency_key
varchar(128)
UQ
Unique per user for 24 hours. This one rule is the whole defence against duplicates.
template
varchar(64)
The template name, not the finished text. Storing finished text means a typo fix cannot reach old messages, and it multiplies storage.
priority
enum
IDX
urgent, standard or bulk. It chooses the lane, and nothing else needs to know it.
state
enum
accepted, suppressed, queued, sent, delivered, failed. Suppressed (we chose not to send) is a real outcome and gets counted.
attempts
int
How many tries. Past the limit it goes to the dead letter queue with the last provider error attached.
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.
Figure 1 is the picture at the top with every box added, including the ones the login code
skipped past.
POST /notifications1render2may we?3by priority4dedupe, cap5quiet hourspull6tokenssend7callbacksfailuresSending servicesany teamTemplatesrender, localiseNotification APIidempotency keyPreferencesopt outs, quiet hoursDedupe + throttledaily cap per userPriority queuesurgent | standard | bulkSchedulerhold until 08:00Device registrytokens per userChannel workerspush, SMS, emailDelivery eventsopens, bouncesRetry + dead letterbackoff with jitterProvidersAPNs, FCM, SMS, SMTP
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:
Any internal service posts a notification and gets a 202 at once. Nothing downstream is
allowed to make a checkout page slow.
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.
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.
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.
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.
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).
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?
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.
counter 1: 40 million parcelsurgent onlya second counter, not a sticker saying urgent
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.
Auth serviceCampaign toolNotification APIQueuesProvidersbatch: 40M users, bulkexpand into the bulk laneOTP for user 4471, urgentstraight to the urgent laneOTP to APNs, about 2scampaign, over the next 20 minutes
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.
hammering: a slow provider becomes a dead onebackoff with jitter: longer waits, a little randomthe gaps grow, and the provider gets room to recover
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.
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.
three a daythe order updatenotifications: offunreachable for ever
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.)
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
12k/s · Healthy. An average day. Every lane is near empty, workers are half idle, and the providers do not notice you.
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.
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.
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.
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.
pushSMSemailbreaker openone channel slows down, the rest keep going
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
The 202
Priority
Duplicates
A provider fails
The person receiving it
Pick 0, 1 or 2 on every row. Be the strict interviewer, not the kind friend.
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.
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.