Skip to content
Software Atlas

Design a Notification System

A system-design case study end to end: push, email, SMS and in-app messages triggered by other services. Requirements, a capacity estimate with the arithmetic shown, per-channel queues with priority lanes, retries with backoff and jitter, deduplication at every hop, per-user caps and quiet hours, and what to do when a provider goes down or says “sent” and never delivers.

⏱ ~50 min read13 sections8 interactive widgetsGo · JavaScript
FoundationsPractitionerStaff-level
Foundations§01

The 30-second version

In one sentenceA notification system takes requests like "tell user 42 their order shipped" from other services, and makes sure the message reaches that person once, on the right channel, at a reasonable time, even when parts of the chain are broken.

Almost every app sends notifications: a Push notification when your food is on the way, an email with a password-reset link, a text message with a login code, a red badge on the bell icon inside the app. Each of these routes is a channel. Big apps don't let every team call Apple, Google and an email company on their own. They build one notification service that everyone calls.

Here's the whole design on one line. You'll build it up piece by piece:

The whole design on one line
Read left to right. Every arrow after the API is asynchronous: nobody waits for the next box.
Callers
order service
login service
campaign tool
API
idempotency key
preferences
store → 202
Queues
push · urgent
push · bulk
email
sms
Workers
dedupe
rate limit
retry / DLQ
Providers
APNs / FCM
email service
SMS gateway
People
phone
inbox
← status callbacks (delivered, bounced, failed) flow back from providers to update each notification's status

Why it's harder than "call the email API"

Sending one message is one HTTP call. The hard part is everything around it:

  • Providers fail. Apple, Google, email and SMS companies (the providers) time out, slow down and return errors. You must retry, but carefully.
  • Retries cause duplicates. If a call timed out, maybe the message went out anyway. Retry blindly and the user gets two texts with two different login codes.
  • Volume is spiky. A marketing message to 10 million users arrives all at once, right when someone else needs a password reset now.
  • People get annoyed. Too many messages, or a buzz at 3 am, and users turn notifications off for good.

The answer, in one paragraph

The API accepts a request (with an Idempotency key so repeats are recognised), checks the user's preferences, and puts one message per channel into a queue (a waiting line of messages kept on a server, like the post office's bins), then answers right away. Workers (background programs that loop forever) take messages from the queues and call the providers. Separate queues per channel and per urgency keep slow or bulky work from blocking urgent work. Failed sends are retried with growing waits, hopeless ones are parked in a dead-letter queue, every worker checks for duplicates before sending, and rate limits protect both users and providers. Status reports that come back from providers later tell you what was actually delivered.

The route through this page

§What you'll learn
02–03What exactly we're building, and how big it is (with the arithmetic)
04The simplest design, and what breaks at 10× and 100× the traffic
05The full pipeline, one notification at a time
06–08Retries, duplicates and rate limits: the three hard parts
09–10When providers fail, and a simulator to break things on purpose
11What you'd watch on a dashboard, and the staff-level trade-offs
Foundations§02

What are we building?

In one sentenceBefore drawing boxes, write down what the system must do (functional requirements) and how well it must do it (non-functional requirements). For notifications, the "how well" list is where all the difficulty lives.

Imagine a large shopping app. The order service, the login service and the marketing team all need to reach users. Our job is the one service they all call.

Functional: what it does

  1. Send on four channels. Push (to phones, through Apple's APNs, the Apple Push Notification service, and Google's FCM, Firebase Cloud Messaging), email, SMS text messages, and in-app messages (the bell icon).
  2. Triggered by other services through an API: "send order_shipped to user 42 with order number 1042".
  3. Respect preferences. Users choose channels and kinds of message. An opt-out ("no promotions") must win every time.
  4. Templates. Callers send a template name plus values; we fill in the text per channel and language. Teams shouldn't paste HTML emails into API calls.
  5. Scheduled and bulk sends. "Send this at 9 am tomorrow", or "send this to these 10 million users".
  6. Status. Callers and support staff can ask: was it sent? Was it delivered?

Two very different kinds of message

The single most important observation in this design: not all notifications are equal.

TransactionalMarketing / bulk
ExamplePassword reset, login code, "order shipped""20% off this weekend"
Sent toOne user, because of something they didMillions at once
Speed neededSeconds. The user is staring at the screen.Minutes or hours are fine
If lostReal harm: user locked outMild: one fewer promo
Caps and quiet hoursUsually exemptAlways applied

Every later decision (separate queues, who gets dropped first under load, which limits apply) follows from this table.

Non-functional: how well it does it

  • Never lose a transactional notification. Once the API says "accepted", the message must survive crashes and restarts.
  • No duplicates. Two "your order shipped" pushes is sloppy; two login codes is confusing and costs money (SMS is paid per message).
  • Don't spam. Per-user caps on marketing, no promos during the night in the user's own time zone.
  • Fast for transactional. Say, 99% of password resets handed to the provider within 5 seconds of the request (an example target; you'd agree on yours with the product team).
  • Survive a provider outage. If the SMS company goes down, login codes should still arrive, perhaps through a second company.
  • Scale to big blasts without hurting everyday traffic.

Out of scope (say it out loud)

  • Writing the marketing campaigns, and choosing who is in a segment ("users who bought shoes"). We receive the final list.
  • Building our own email servers or SMS network. We use outside providers.
  • Chat between users. That's a different system: see Design a Chat System.
Foundations§03

How big is it? A rough estimate

In one sentenceA back-of-the-envelope estimate uses round numbers to find which number is scary. Here it isn't the daily total. It's the blast.

All numbers below are approximate and invented for a large, typical shopping app. They are not any real company's figures, and the provider limit is illustrative: real limits depend on your provider, contract and setup, so look them up in your provider's documentation.

Step 1: the inputs

  • 50 million daily active users (DAU).
  • Per user per day, on average: 4 pushes, 0.5 emails, 0.05 SMS (texts cost money, so they're used for login codes only) and 2 in-app messages.
  • A day has 86,400 seconds. The busiest hour is about 3× the average: the peak factor.
  • One marketing blast: 10 million users, and the marketing team would like it out within 10 minutes.
  • Our push provider setup accepts about 5,000 pushes a second (illustrative).

Step 2: everyday traffic per channel

Push: 50,000,000 × 4 = 200,000,000 a day. 200,000,000 ÷ 86,400 ≈ 2,300 per second on average, × 3 ≈ 7,000/s at peak.

Email: 50,000,000 × 0.5 = 25,000,000 a day ≈ 290/s, ≈ 900/s at peak.

SMS: 50,000,000 × 0.05 = 2,500,000 a day ≈ 29/s, ≈ 90/s at peak.

In-app: 50,000,000 × 2 = 100,000,000 a day ≈ 1,200/s. These are just rows written to our own database, no provider involved.

Total: about 330 million notifications a day, about 3,800/s on average and 11,000/s at peak. That's busy, but each send is a small HTTP call. A few dozen worker machines handle it.

Step 3: the blast (the scary number)

10,000,000 pushes in 10 minutes = 10,000,000 ÷ 600 s ≈ 16,700 per second.

That's more than twice our normal push peak (7,000/s), arriving in one go. Worse, the provider only accepts 5,000/s:

10,000,000 ÷ 5,000 per second = 2,000 s ≈ 33 minutes, no matter how many workers we add.

So the blast can't go out in 10 minutes. It must wait in a queue and drain at the provider's pace. Now picture a user who taps "forgot password" one second after the blast was queued. If their message joins the same line, it waits behind up to 10 million others: up to 33 minutes for a password reset. That one number explains most of §05.

Step 4: storage and bandwidth

Notification log (who, what, which channel, status, timestamps): about 500 bytes a row. 330,000,000 × 500 bytes ≈ 165 GB a day. Kept for 30 days: 165 GB × 30 ≈ 5 TB. A sharded database (see Sharding) or a log store, not a single small server.

Dedupe memory (§07): remember every sent ID for 24 hours, about 100 bytes each. 230,000,000 provider sends × 100 bytes ≈ 23 GB. Fits in memory on a small Redis cluster.

Bandwidth: a push is small, say 1 KB: 7,000/s × 1 KB ≈ 7 MB/s. An HTML email is more like 50 KB: 900/s × 50 KB ≈ 45 MB/s ≈ 360 Mbit/s. Email, not push, dominates the bytes.

Play with the inputs

Change the inputs and watch which outputs move. The widget doesn't round the inputs first, so with the defaults it shows slightly different figures (6,900/s instead of 7,000, 4.9 TB instead of 5 TB). Same estimate, less rounding. Try a 50-million-user blast, or halving the provider limit.

Capacity estimator
Approximate, with round numbers. The provider limit is illustrative, not any real provider's.
OutputFormulaResult
Push per second (peak)50M × 4 ÷ 86,400 × 36,900/s
Email per second (peak)50M × 0.5 ÷ 86,400 × 3870/s
SMS per second (peak)50M × 0.05 ÷ 86,400 × 387/s
Blast rate wanted10M ÷ (10 min × 60)17,000/s
Blast drain time at the provider limit10M ÷ 5,000/s33 min
Worst wait for a reset in a shared linesame as the drain time33 min
Notification log, 30 days(provider sends + 50M × 2 in-app) × 500 B × 304.9 TB
Dedupe memory (24 h window)provider sends per day × 100 B23 GB
Email bandwidth (peak)email/s × 50 KB43 MB/s

Red means trouble: the blast can't finish in time at the provider's rate, so it has to wait in a queue, and anything stuck behind it waits too. Total provider sends: 230M a day.

Foundations§04

Version one, and what breaks

In one sentenceStart with the simplest design that meets the requirements, then grow traffic in your head and watch what breaks first. Each fix should answer a specific break.

Version zero: call the provider inline

The tempting first try: the order service calls the email provider right inside its "ship order" code.

This fails quickly. If the provider takes 3 seconds, so does shipping the order. If the provider is down, either the order fails (bad) or the email is silently lost (also bad). And every team re-implements preferences, templates and retries. So we pull sending out into its own service, and make it asynchronous (the caller doesn't wait for the send).

Version one: a table and a worker

  1. The notification API writes one row per message into a notifications table with status = 'pending', and answers 202 Accepted ("got it, will do"), not "sent".
  2. A worker process loops: grab some pending rows, call the provider, mark each row sent or failed.
worker.sql
-- Grab up to 100 pending rows that no other worker is holding right now.
SELECT id, user_id, channel, payload
FROM notifications
WHERE status = 'pending' AND send_after <= now()
ORDER BY created_at
LIMIT 100
FOR UPDATE SKIP LOCKED;   -- ← rows another worker has locked are skipped, not waited for

One detail: don't hold that transaction open while calling providers, or rows stay locked for the whole slow batch. Claim the rows instead (UPDATE … SET status = 'sending' on the selected IDs), commit, then send and update each row afterwards. Store a claimed_at time too, so rows left in sending by a crashed worker can be picked up again after a timeout.

This is honestly a good design for a small app. Writes are durable (in the database), restarts are safe (unfinished rows stay pending), scheduled sends are one column. At 1 million notifications a day (about 12 a second), one database and a couple of workers are plenty.

Grow it: 10× and 100×

Drag the slider. Components appear as traffic grows, and the current bottleneck is highlighted in red.

Architecture vs traffic
Drag the slider. The red box is what breaks next. Rule of thumb: one worker sending one at a time ≈ 10 sends a second.
Callers
Notification API
notifications tablepending → sent
1 worker (all channels)
Providers
A table plus one worker. Durable, simple, scheduled sends are a column. Nothing here is under pressure.

At 10× (10M a day: 10,000,000 ÷ 86,400 ≈ 115/s, × 3 ≈ 350/s at peak), the first break is the worker. A provider call takes, say, 100 ms, so one worker sending one message at a time manages about 10 a second. 350 ÷ 10 = 35 workers, or fewer workers that each send many messages at once. The second break: one loop for all channels means a slow email provider holds up pushes. So: one pool of workers per channel.

At 100× (100M a day ≈ 1,200/s, ≈ 3,500/s at peak), the table itself breaks. Every message is inserted, polled, locked and updated several times: say 4 writes each, so 3,500 × 4 ≈ 14,000 writes a second, all on one database. Meanwhile a blast like the one in §03 fills the table with 10 million rows that the workers must chew through before reaching a password reset. §03's full numbers (330M a day, ~11,000/s at peak, about 330×) only make this worse. The fixes, each covered in its own section:

  • A Message broker (Kafka, RabbitMQ, SQS…) with one queue per channel replaces polling the table (§05).
  • Priority lanes: separate queues for transactional and bulk (§05).
  • Rate limiters in front of each provider, and per-user caps (§08).
  • A dedupe store and retry queues with growing waits (§06, §07).
  • A second provider per channel for failover (§09).
Practitioner§05

The pipeline, one notification at a time

In one sentenceAccept fast, send later. The API's only job is to decide what to send and store it safely. Everything slow (providers, retries) happens behind queues, and every channel and urgency gets its own queue.

The request

The order service calls us like this. The Idempotency-Key header is a unique ID the caller picks for this one intent ("tell user 42 about order 1042 shipping"). If the caller times out and sends the same request again, the same key tells us it's a repeat. (Idempotency keys, end to end covers the full protocol.)

request
POST /v1/notifications
Idempotency-Key: order-1042-shipped
Content-Type: application/json

{ "user_id": 42, "template": "order_shipped", "priority": "transactional",
  "params": { "order_id": 1042, "eta": "Tuesday" } }
response
HTTP/1.1 202 Accepted

{ "notification_id": "ntf_7f3a", "status": "accepted" }

202 Accepted means "stored, will be sent", never "delivered". The caller can look up ntf_7f3a later to see what happened.

Step through one notification

Life of one notification
“Order 1042 shipped” for user 42, from API call to phone. Use ← → or Play.
API
POST /v1/notifications
Idempotency-Key: order-1042-shipped
Queues
empty
Worker
idle
Provider → user
nothing yet
notifications table
no row
The order service asks: send order_shipped to user 42. It picks the idempotency key order-1042-shipped, the same key on every retry of this request.
1 / 8

What each box does, and why it's separate

The API validates the request, checks the idempotency key, loads the user's preferences and device tokens (both read constantly, so they sit in a Cache in front of the database; see cache-aside), and writes one message per allowed channel. Rendering the template into final text can happen here or in the channel worker. Rendering late, in the worker, keeps the API fast.

Durability before the 202. The API must not answer until the message is stored somewhere that survives a crash. If it writes a database row and then publishes to the broker, a crash in between loses the message. Two standard fixes: write the row and an outbox entry in one transaction, and let a relay publish it (the Outbox pattern, see Queues §exactly-once); or make the broker the first write and wait for its confirmation before answering.

One queue per channel is a Bulkhead: if the email provider slows to a crawl, the email queue grows, but push and SMS workers keep going. Each channel also has its own worker pool sized for its own provider.

Priority lanes split each channel again: push.transactional and push.bulk, with their own workers. A password reset never stands in line behind the blast, because it's in a different line. Bulk workers are deliberately limited so that bulk traffic can never use up the whole provider allowance.

Blasts take a different entrance

A 10-million-user campaign isn't 10 million API calls. It's one request ("send weekend_sale to segment s_881, start 09:00"). A fan-out job reads the user list in pages, checks each user's preferences, and writes messages into the bulk lanes at a controlled pace. Fan-out just means one thing turning into many copies. Doing it in a background job means a blast can't flood the API, and it can be paused halfway.

The tables, roughly

TableHoldsRead by
notificationsid, user, template, channel, priority, status, attempts, provider message ID, timestampsstatus API, support, reconciliation (§09)
preferencesuser → allowed channels and kinds, time zone, quiet hoursAPI, fan-out job (cached)
device_tokensuser → push tokens per devicepush workers (cached)
idempotency_keyscaller + key → notification ID, kept 24 h or moreAPI
Practitioner§06

Retries without a storm

In one sentenceA failed send is either worth retrying (the problem may go away) or not (it never will). Retry the first kind with growing, randomized waits and a cap; drop the second kind immediately.

Which errors to retry

Providers answer with HTTP status codes, or don't answer at all. Sort every outcome into one of three buckets:

OutcomeMeaningAction
Timeout, connection resetUnknown: it may or may not have been sentRetry (the dedupe check in §07 makes this safe)
429 Too Many RequestsWe're over the provider's limitRetry later, honouring its Retry-After header if present
5xx (500, 502, 503…)Provider troubleRetry with backoff
400 bad request, invalid email address, bad payloadOur request is wrongDrop and log. Retrying sends the same bad request.
Device token unregistered (as of October 2026, APNs answers 410 with reason Unregistered; FCM answers 404 with UNREGISTERED)App uninstalled or token replacedDrop and delete the token, so we stop paying to send to it

A timeout is the tricky one: it's an Unknown outcome. The provider may have sent the push and the reply got lost. That's why retries and deduplication always come as a pair. (Success, failure, and "I don't know" explains why you can't avoid this case.)

retry.go
// Outcome is what the provider told us (or didn't).
type Outcome struct {
	Status       int  // HTTP status from the provider; 0 = timeout / no answer
	InvalidToken bool // set by parsing the reply: APNs 410 Unregistered, FCM 404 UNREGISTERED
}

// decide: retry later, drop for good, or drop and delete the token.
func decide(o Outcome) string {
	switch {
	case o.InvalidToken:
		return "drop + delete token" // ← retrying can never help
	case o.Status == 0, o.Status == 429, o.Status >= 500:
		return "retry" // timeout, "slow down", provider trouble: may work later
	case o.Status >= 400:
		return "drop" // our request is wrong (bad address, bad payload)
	default:
		return "done"
	}
}

// ceiling is the longest we may wait before retry number `attempt` (1, 2, 3…).
func ceiling(attempt int) time.Duration {
	base, maxWait := time.Second, 5*time.Minute
	return min(base<<(attempt-1), maxWait) // 1s, 2s, 4s, 8s… never above 5 min
}

// backoff picks the actual wait: anywhere from 0 up to the ceiling ("full jitter").
func backoff(attempt int) time.Duration {
	return rand.N(ceiling(attempt)) // math/rand/v2
}

Running both with a few outcomes and the first ten retries (this is the JS output; Go prints the last four as 1m4s, 2m8s, 4m16s, 5m0s, otherwise it's identical):

output
status 200 → done
status   0 → retry
status 503 → retry
status 429 → retry
status 400 → drop
status 410 → drop + delete token
retry  1: wait up to 1s
retry  2: wait up to 2s
retry  3: wait up to 4s
retry  4: wait up to 8s
retry  5: wait up to 16s
retry  6: wait up to 32s
retry  7: wait up to 64s
retry  8: wait up to 128s
retry  9: wait up to 256s
retry 10: wait up to 300s

Why the waits grow, and why they're random

Exponential backoff (doubling the wait each time) gives a struggling provider room to recover. Jitter (picking a random wait up to the ceiling) stops everyone retrying at the same instant. Without it, 10,000 messages that failed together at 09:00:00 all come back at 09:00:01, fail together again, come back at 09:00:03… Each wave is a Thundering herd that knocks the provider over again just as it recovers.

Retry timeline
1,000 pushes fail at once when the provider goes down. It comes back at the time you pick, and then handles 150 a second. Send it more than that and it slows down, so fewer succeed.
01005001000capacity 150/sprovider down0s15s30s45s60s75s
acceptedfailed (comes back later)bar height uses a square-root scale so small bars stay visible
StrategyCalls madeDelivered in 90 sAll done at
retry every 1 s23,9581,00031 s
backoff, no jitter8,773116> 90 s
backoff + jitter5,3021,00026 s

Fixed retries hammer the provider with every remaining message every second. It stays overloaded, so only a trickle succeeds each second: a retry storm, with several times the calls. Backoff without jitter looks even worse here, and that's the point: it makes far fewer calls, but the whole herd still arrives at the same instant (1 s, 3 s, 7 s, 15 s…), just less often. Each wave is as overloaded as a fixed retry, and there are far fewer waves, so less gets through. Backoff with jitter spreads the same herd across time, under the capacity line: the fewest calls and the fastest finish. Backoff decides how much to wait; jitter makes sure everyone doesn't wait the same amount.

Where the waiting happens

A worker shouldn't sleep for 4 minutes holding a message: it would sit idle, and a restart would lose track of the wait. Instead, on a retryable failure the worker re-queues the message with a delay (attempts + 1, not_before = now + backoff) and acks the original (tells the broker it's done with that copy). Brokers offer this in different ways: SQS has per-message delays (up to 15 minutes), RabbitMQ setups use a "retry" queue whose messages expire back into the main one, and with Kafka people use separate retry topics (e.g. push.retry.1m, push.retry.10m).

Budgets and the dead-letter queue

  • Cap the attempts. After, say, 8 attempts (7 retries, so at most about 2 minutes of waiting in total: 1 + 2 + 4 + … + 64 s) or a deadline, stop. A transactional message that's an hour late is often useless anyway: a login code expired long ago.
  • A Retry budget. If more than, say, 10% of sends this minute are retries, stop adding new ones. A provider outage then doesn't triple our own traffic.
  • The Dead-letter queue (DLQ). A message that ran out of attempts, or that crashes the worker every time (a Poison message), goes to a separate queue with an alert, so a human can inspect it and re-send ("redrive") after a fix. Poison messages and dead-letter queues covers the mechanics.
1

Retrying a 400 forever

A malformed phone number fails the same way every time. Without the "drop" bucket, each such message burns its full 8 attempts, and a bad batch of a million users can fill the retry queues and delay good messages. Classify first, retry second.

Practitioner§07

No duplicates: dedupe at every hop

In one sentenceDuplicates sneak in at every hop: the caller retries, the queue redelivers, our worker retries a timeout. So every hop checks "have I already done this one?", using an ID that stays the same across all the retries.

Where duplicates come from

  1. The caller retries the API. Their request timed out, so they send it again. → The API's Idempotency key check catches it (§05).
  2. The queue delivers twice. Brokers promise At-least-once delivery delivery: if a worker takes a message and doesn't ack it in time (crash, restart, slow), the broker hands it to another worker. → The worker must dedupe. Exactly-once: what's really on offer explains why no broker setting removes this.
  3. We retry a timeout that actually worked. The provider sent it; only the reply was lost. → Same worker check, plus the tricks at the end of this section.
  4. A blast job restarts halfway. It re-reads the user list from the start. → Give each message a deterministic ID, such as campaign_881:user_42, so the re-run produces the same IDs and the worker skips the ones already sent.

What exactly is "one" message?

Before any check, decide what the dedupe key names. One notification can become several deliveries: ntf_7f3a goes out by push to Mia's iPhone, by push to her iPad, and by email. Each of those must be sent exactly once, independently. So the worker's key is notification + channel (+ device for push):

ntf_7f3a:push:iphone-8c2   ntf_7f3a:push:ipad-41d   ntf_7f3a:email

If the key were just ntf_7f3a, the push worker would mark it "sent", and the email worker would then find "sent" and skip the email: a lost notification, caused by the dedupe check itself.

The worker check: claim, send, mark

The simple version, "if I've seen this key, skip; otherwise send and remember it", has a gap: when do you remember it?

  • Remember after sending: a crash between "provider accepted" and "remember" means the redelivered copy is sent again. A duplicate.
  • Remember before sending: a crash after "remember" but before the send means the redelivered copy is skipped. The message is lost, and a lost password reset is worse than a duplicate.

The usual answer is a claim with a Lease: write "sending" with a short expiry (say 30 seconds) before calling the provider, and "sent" with a long expiry after. Then a redelivered copy can find three things, and each needs a different action:

The store saysMeaningWorker does
nothing (or an expired claim)Nobody has it, or the last worker diedClaim it and send
"sending", lease still liveAnother worker is on it right nowRequeue with a short delay, don't ack-and-forget
"sent"Already doneSkip and ack
Worker crashes mid-send
The same crash, without and with a dedupe claim.
Queue
ntf_9 (login code)
Dedupe store (none)
not used
Worker A
idle
Worker B
idle
User's phone
no messages
A login-code SMS, ntf_9, waits in the queue. This version has no dedupe check: workers just send whatever they receive.
1 / 6
dedupe.go
type entry struct {
	state string    // "sending" or "sent"
	until time.Time // when this entry expires
}

// Dedupe remembers which deliveries were sent. A key is one delivery:
// notification + channel (+ device for push), e.g. "ntf_1:push:iphone-a".
type Dedupe struct {
	mu   sync.Mutex
	seen map[string]entry
}

// Begin tries to claim key for `lease`. It returns
//
//	"claimed" → it's yours: send, then call Done
//	"sending" → another worker holds a live claim: requeue, try later
//	"sent"    → already sent: skip it and ack
func (d *Dedupe) Begin(key string, now time.Time, lease time.Duration) string {
	d.mu.Lock()
	defer d.mu.Unlock()
	if e, ok := d.seen[key]; ok && now.Before(e.until) {
		return e.state // ← "sending" or "sent": don't send
	}
	d.seen[key] = entry{"sending", now.Add(lease)} // new, or an expired claim
	return "claimed"
}

// Done marks key as sent and remembers it for the dedupe window.
func (d *Dedupe) Done(key string, now time.Time, window time.Duration) {
	d.mu.Lock()
	defer d.mu.Unlock()
	d.seen[key] = entry{"sent", now.Add(window)}
}

func main() {
	d := &Dedupe{seen: map[string]entry{}}
	t0 := time.Date(2026, 10, 31, 9, 0, 0, 0, time.UTC)
	lease, window := 30*time.Second, 24*time.Hour
	show := func(key, when, got string) { fmt.Printf("%-20s %-15s → %s\n", key, when, got) }

	// ntf_1 goes out by push and by email: two deliveries, two keys.
	show("ntf_1:push:iphone-a", "first delivery", d.Begin("ntf_1:push:iphone-a", t0, lease))
	show("ntf_1:email", "first delivery", d.Begin("ntf_1:email", t0, lease))
	d.Done("ntf_1:push:iphone-a", t0.Add(time.Second), window)
	show("ntf_1:push:iphone-a", "redelivered", d.Begin("ntf_1:push:iphone-a", t0.Add(time.Minute), lease))

	// ntf_2: the worker claims it and crashes before calling Done.
	show("ntf_2:sms", "first delivery", d.Begin("ntf_2:sms", t0, lease))
	show("ntf_2:sms", "after 10s", d.Begin("ntf_2:sms", t0.Add(10*time.Second), lease))
	show("ntf_2:sms", "after 31s", d.Begin("ntf_2:sms", t0.Add(31*time.Second), lease))
}

Both print the same thing:

output
ntf_1:push:iphone-a  first delivery  → claimed
ntf_1:email          first delivery  → claimed
ntf_1:push:iphone-a  redelivered     → sent
ntf_2:sms            first delivery  → claimed
ntf_2:sms            after 10s       → sending
ntf_2:sms            after 31s       → claimed

The email of ntf_1 is claimed even though its push was already sent: different keys. The redelivered push is skipped. And ntf_2's crashed claim blocks other workers for 30 seconds, then becomes claimable again.

In production the map is shared by all workers, typically in Redis. SET key sending NX PX 30000 means "set this key only if it doesn't exist (NX), expiring in 30,000 ms (PX)". It answers OK (claimed) or nothing (someone has it). To tell "sending" from "sent" when it fails, follow it with GET key, or do both in one small Lua script so no other worker can sneak in between. After the send, SET key sent EX 86400 keeps it for a day: the dedupe window. §03 estimated that memory at about 23 GB.

Three timeouts that must line up

The lease only works if it outlasts the work it protects. Picture worker A, alive but slow: its provider call hangs for 40 seconds. At 30 s the lease expires, worker B claims the key and sends, and then A's call finishes too. Both sent, and nobody crashed. Two rules prevent it:

  1. Lease > the longest a send can take. Put a hard deadline on the whole send (rendering plus the provider call): context.WithTimeout in Go, AbortSignal.timeout() in JS. With a 5-second provider timeout (§09), a 30-second lease leaves plenty of room. A worker that misses its deadline gives up rather than finishing late.
  2. Broker redelivery timeout > the lease. The broker's Visibility timeout (how long it hides a taken message before handing it to someone else) should be longer than the lease, say 60 s. Otherwise the broker redelivers while A's claim is still live, and B just requeues it again and again.

The gap nobody can close

Look at ntf_2 again. If the first worker crashed after the provider accepted the message but before Done, the claim at 31 s sends it a second time. No check on our side can tell "crashed before the send" from "crashed after", because the provider's answer was lost with the worker. Two ways to soften it:

  • Pass your key downstream. Some email and SMS provider APIs accept an idempotency key; if yours does, send the delivery key and let them drop the repeat. (A "client reference" or tag field usually doesn't dedupe; it only labels the message so you can match it up later.)
  • Replace instead of repeat, for push. Both push services let you label a notification so that a newer one with the same label replaces the older one on screen instead of adding a second banner. Use the delivery key as the label and a duplicate push mostly disappears:
    • APNs (Apple Push Notification service): the apns-collapse-id header, at most 64 bytes. Apple documents it as merging notifications with the same ID into one for the user.
    • FCM (Firebase Cloud Messaging), on Android: the android.notification.tag field. A notification with the same tag replaces the one already shown.
    • Not FCM's collapse_key. Despite the name, it does something else: while a device is offline, FCM may replace a pending, not yet delivered message with a newer one carrying the same key. It doesn't touch a banner already on screen, notification messages ignore it, and FCM keeps at most 4 different collapse keys per device. Giving every message its own collapse_key can make FCM discard pending messages for an offline phone.

A safety net above the IDs

ID checks only work if the caller reuses the same key. A buggy upstream service that generates a new key on every retry slips right past them. Many teams add a coarse content check: "the same template to the same user within 10 minutes? Hold it." It catches bugs the ID checks can't.

Practitioner§08

Don't spam: caps, quiet hours, provider limits

In one sentenceTwo kinds of limits, protecting two different parties: per-user limits protect people from us, and per-provider limits protect the provider (and our account) from us.

Per-user: frequency caps

A frequency cap says "at most N of this kind per period", for example 3 marketing pushes per user per day. It's the single best defence against users turning notifications off entirely, which loses you the channel for good.

The check runs in the worker (or the fan-out job) just before sending. It's a counter per user per day, usually in Redis: increment cap:42:2026-10-31:marketing, and if the result is above 3, skip the message. The date in the key is the user's local date (a "day" is their day, not the server's), and the counter is only incremented when a message is actually sent, so a message held for quiet hours counts toward the day it goes out. That's a fixed-window counter, and here its simplicity is a feature: "per calendar day" is exactly what product people mean. Transactional messages are exempt. You never cap password resets.

What happens to a message over the cap? For marketing, drop it (and record why, so the dashboard can show it). For something like "someone liked your photo", merge it instead: "Mia and 4 others liked your photo".

Per-user: quiet hours and time zones

Quiet hours, say 22:00 to 08:00, are in the user's Time zone, not the server's. A blast queued at 16:30 UTC lands at 23:30 in Hanoi, 16:30 in London and 12:30 in New York. So each user's message is checked on its own: send now, or hold it until 08:00 their time. ("UTC hygiene" in Clocks: time in real systems covers storing UTC and converting only for display.)

quiet.go
// Quiet hours: no marketing between 22:00 and 08:00 in the user's own time zone.
const quietStart, quietEnd = 22, 8

// sendAt returns when a marketing message may go out: now, or the next 08:00 local.
func sendAt(now time.Time, zone string) (time.Time, error) {
	loc, err := time.LoadLocation(zone) // e.g. "Asia/Ho_Chi_Minh"
	if err != nil {
		return time.Time{}, err
	}
	local := now.In(loc) // same instant, shown on the user's wall clock
	h := local.Hour()
	if h >= quietEnd && h < quietStart {
		return now, nil // daytime: send now
	}
	day := local
	if h >= quietStart {
		day = local.AddDate(0, 0, 1) // late evening → 08:00 tomorrow
	}
	return time.Date(day.Year(), day.Month(), day.Day(), quietEnd, 0, 0, 0, loc), nil
}

func main() {
	now := time.Date(2026, 10, 31, 16, 30, 0, 0, time.UTC) // one moment, three users
	for _, zone := range []string{"Asia/Ho_Chi_Minh", "Europe/London", "America/New_York"} {
		loc, _ := time.LoadLocation(zone)
		at, _ := sendAt(now, zone)
		fmt.Printf("%-17s local %s → send at %s UTC\n", zone,
			now.In(loc).Format("15:04"), at.UTC().Format("Jan 2 15:04"))
	}
}
output (Go)
Asia/Ho_Chi_Minh  local 23:30 → send at Nov 1 01:00 UTC
Europe/London     local 16:30 → send at Oct 31 16:30 UTC
America/New_York  local 12:30 → send at Oct 31 16:30 UTC
output (JS)
Asia/Ho_Chi_Minh  local 23:30 → send at 2026-11-01T01:00:00.000Z
Europe/London     local 16:30 → send at 2026-10-31T16:30:00.000Z
America/New_York  local 12:30 → send at 2026-10-31T16:30:00.000Z

The Hanoi user's message is held until 08:00 their time, which is 01:00 UTC. The JS version counts minutes on the wall clock, so on a night when the clocks change (Daylight saving time) it can be an hour off; the Go version asks for "08:00 local" directly and doesn't have that problem.

Held messages go back into the bulk lane with send_after set (the same delayed re-queue as retries in §06). Watch out for the knock-on effect: if a whole country's held messages all become due at exactly 08:00:00, you've created a mini-blast. Spreading them over the first 30 minutes (jitter again) smooths it out.

Try the checks together. Pick a user, a time and a message type:

Should this notification go out now?
Per-user checks in the order a worker runs them. Offsets are for 31 Oct 2026.
User's time zone
Message type
User's wall clock: 23:30 (Hanoi, UTC+7)
Message typemarketing → all checks apply
Preferencespromotions allowed ✓
Quiet hours23:30 local is inside 22:00–08:00 ✗
HOLD until 08:00 local
That's 01:00 UTC, 8.5 h from now. It goes back on the bulk lane with send_after set (plus a little jitter). The cap is checked when it comes due, against the user's local day it actually goes out on.

Per-provider: stay under their limit

Every provider limits how fast you may send (§03 used an illustrative 5,000 pushes a second). Go over it and you get 429 errors, which turn into retries, which make things worse. Rather than discover the limit by hitting it, each channel's workers pace themselves with a Token bucket: a bucket that refills at the provider's rate, and each send takes one token. No token, wait. Token bucket and leaky bucket explains the two knobs (rate and burst).

Two details matter here:

  • The limit is shared by all workers. Twenty workers each limiting themselves to 5,000/s would send 100,000/s. Either give each worker a slice (5,000 ÷ 20 = 250/s each) or use one shared bucket in Redis, as in Many servers, one limit.
  • Split the allowance between lanes. For example, bulk gets at most 70% of the provider's rate, so even a full blast leaves room for transactional sends.
Staff-level§09

When the provider fails

In one sentenceA provider's 200 OK means "I accepted it", not "the user has it". Treat providers as unreliable partners: put a time limit on every call, stop calling them when they're clearly broken, keep a second one ready, and listen for what happened afterwards.

Sent is not delivered

"Sent" and "delivered" are different facts, learned at different times:

  • Sent: the provider answered our call with success. We know this right away.
  • Delivered: the email reached the inbox, the SMS reached the phone. We learn it later, from a status callback: a Webhook the provider sends to an endpoint of ours, saying "message abc was delivered" (or bounced, failed, marked as spam).
  • Push is special. APNs (Apple Push Notification service) and FCM (Firebase Cloud Messaging) accept the message, but neither calls a webhook of yours per message saying "it was shown". Teams that need that number have the app itself report back when a notification is received or opened. FCM can also export, optionally and with a delay, per-message "delivered to the app on the device" data to BigQuery (Google's data warehouse) for Android, plus aggregate delivery reports. That's useful for dashboards, not for updating one notification's status in real time. (As of October 2026; check the Firebase docs.)

So each notification moves through states, and different events move it. Click the events to drive one message through its life:

A notification's status, event by event
Click an event to move the message. Final states have no events (except a late duplicate callback).
acceptedqueuedsendingretryingsentdeliveredunknowndroppedfaileddeadbounced
accepted: Stored by the API, 202 returned. Nothing sent yet.
▸ accepted (attempt 1)

The status-callback endpoint is a busy little service of its own (from §03's peak numbers, email 900/s + SMS 90/s ≈ 1,000 callbacks a second, and more if a provider sends several events per message: delivered, opened, clicked). It must check the callback is really from the provider (providers sign them, often with an HMAC), find our notification by the provider's message ID that we saved when sending, and update its status. Callbacks can arrive late, twice, or out of order ("delivered" before "sent" was recorded), so updates must only move a status forward, never back.

What the callbacks tell you to do

  • Hard bounce (address doesn't exist) or spam complaint: add the address to a suppression list and never send to it again. Email providers watch your bounce and complaint rates, and a high rate gets your mail sent to spam folders, for everyone.
  • Dead device token (from the send response, §06): delete it.
  • Undeliverable SMS number: mark the number invalid and ask the user to update it.

Timeouts and circuit breakers

Every provider call gets a short Timeout, say 5 seconds for push. Without one, a provider that hangs instead of failing holds every worker hostage, and the queues grow while the workers wait.

When a provider is clearly down (say, more than half of the last 100 calls failed), retrying every message just burns the retry budget. A Circuit breaker watches the failure rate and, past a threshold, opens: workers stop calling that provider and fail fast instead. After a pause it lets a few test calls through ("half-open"); if they succeed, it closes again. While the breaker is open, messages wait in the queue, or go to the backup provider.

Failover to a second provider

For channels where a loss really hurts, keep a second provider under contract: a second SMS gateway or email service. When the main one's breaker opens, failover routes new sends to the backup. Some caveats:

  • Push has no real second provider. In practice, push to an iPhone goes through APNs: even FCM's iOS SDK delivers through APNs under the hood. An Android phone with Google services receives through FCM. So if APNs is down, push to iPhones waits. Your fallback is another channel: send the login code by SMS or email instead. (As of October 2026. Rules like the EU's Digital Markets Act keep changing what platforms must allow, so check the current state.)
  • The backup must be ready. Templates, sender names or numbers, and approved message formats all have to exist there already, and the backup needs some live traffic (say 5%) every day, or you discover it's broken during the outage.
  • Failover can duplicate. A message that timed out at provider A may have gone out, and now B sends it too. For a login code, that's an acceptable price.

The provider that says 200 and goes silent

The nastiest failure: the provider accepts everything, returns 200, and then delivers nothing, or delivers hours late. Our dashboards show green. Two defences:

  • A reconciliation job that finds messages stuck in sent with no callback after, say, an hour, and either asks the provider's status API or marks them unknown and alerts. (The general idea is Reconciliation: compare your records with theirs and fix the differences.)
  • A delivery-rate alarm: if the share of email or SMS reaching delivered within 10 minutes drops from 98% to 60%, page someone, and consider failing over even though no call is failing.
Staff-level§10

Failure drills: break it on purpose

In one sentenceFor every box in the design, ask "what happens when it dies, or gets slow?" Then check that the answer is something users can live with.

The simulator

Every dot is a notification: transactional or bulk. Turn on trouble and watch the queues, the retries, the dead-letter queue and the counters. Try these four drills in order:

  1. Start a blast with priority lanes off. Watch how long transactional messages wait (the "oldest tx waiting" number). Then turn lanes on and start another blast.
  2. Kill provider A. A few sends fail and retry, then the circuit breaker opens and messages pile up in the queue (failover is off). Turn failover on.
  3. Crash workers mid-send with dedupe off, then on. Watch the duplicates counter. With dedupe on it drops sharply but not to zero, and the log shows why: each remaining duplicate came from a crash before "sent" was written, the gap from §07.
  4. Inject a poison message and watch it bounce through retries into the DLQ.
Pipeline simulator
One tick = one simulated second. The provider takes 10 sends a tick; 2 transactional messages arrive every tick.
t = 0 s
Design
Trouble
push.tx lane0
empty
push.bulk lane0
empty
retry (backoff)0
empty
DLQ0
empty
Providers
A: healthy
B: not configured
oldest tx waiting
0 s
delivered tx
0
delivered bulk
0
duplicates
0
transactionalbulkpoisona retry
Press Run, then try drill 1: turn priority lanes off and start a blast. Watch “oldest tx waiting”.

The failure modes, one by one

A blast delays password resets. Covered in §03 and §05: with one shared line, the reset waits behind 10 million messages, up to half an hour. Separate priority lanes with reserved workers and a reserved slice of the provider's rate fix it. Monitor: the age of the oldest message in the transactional lane.

A poison message. One message makes the worker crash every time (a template that fails to render for one user's name, say). Without an attempt cap, it's redelivered forever and keeps killing workers. With a cap, it lands in the Dead-letter queue after N tries and the lane keeps flowing. Monitor: DLQ size, with an alert on any growth.

A worker crashes mid-send. The broker redelivers the message to another worker. The dedupe claim (§07) skips it if it was marked sent, waits if the lease is live, and resends if the lease expired, which is the rare duplicate we accepted. Monitor: how often a worker takes over an expired claim (each one is a possible duplicate), plus user reports.

A provider says 200 and never delivers. No error anywhere; only the delivered rate falls. The reconciliation job and a delivery-rate alarm catch it (§09).

The provider is down. The breaker opens, messages wait or fail over. Transactional SMS and email move to the backup provider; push falls back to another channel for urgent messages and waits for the rest.

What users see when each component dies

If this dies…Users seeRecovery
One API serverNothing: the Load balancer sends callers to the othersRestart it
The whole APICallers get errors and retry with the same idempotency keyCallers' retries succeed once it's back, no duplicates
The brokerAPI can't accept (or buffers briefly), sends stopA replicated broker survives one node; queued messages are on disk
A workerNothing, other workers take its messagesRedelivery + dedupe
The dedupe store (Redis)Choice: pause sending, or send without dedupe (risk duplicates)Most teams keep sending transactional, pause bulk
Preferences databaseAPI can't check opt-outsServe from cache; if the cache is cold too, refuse marketing, keep transactional
A providerDelays on that channelBreaker, backup provider or channel, retries
Staff-level§11

What to monitor, and staff-level trade-offs

In one sentenceMeasure the system the way the user feels it: how long until the message left our hands, and did it arrive. Queue sizes and error counts are the early warnings behind those two numbers.

The dashboard

MetricWhy it mattersAlert when (example)
Age of the oldest message, per laneThe best single health number. Queue size is normal during a blast; a 2-minute-old password reset is not.Transactional lane oldest > 30 s
Time from API to provider, transactional, p99The SLO from §02: 99% within 5 sBreaching for 5 minutes
Provider error rate, per provider, by statusSpots outages and our own bad requests (400s) separately> 5% 5xx, or any jump in 400s after a deploy
Delivered rate (email, SMS) within 10 minCatches "200 then silence" (§09)Drops below the usual 98%
Retry and DLQ volumeRetries eat capacity; DLQ growth means something is permanently wrongDLQ grows at all
Breaker state, per providerShows failovers as they happenAny open breaker
Opt-out and complaint rate after each campaignThe cost of spam, in users lostOpt-outs per 1,000 sends doubles
Dropped by cap / quiet hours / preferenceExplains "why didn't I get it?" tickets; a sudden change usually means a bugSudden change

The broker's Consumer lag is the same idea as oldest-message age, measured in messages instead of seconds. Seconds are easier to set an alert on, because the right number of messages changes with traffic. Observability covers how to record these.

Trade-offs a staff engineer raises

Build or buy. Hosted notification platforms bundle templates, preferences, channels and provider failover. For a small team, buying is often right. Building pays off at large volume, with unusual routing rules, or when notifications are core to the product. Either way, the concepts on this page are what you'll configure or debug.

Cost, not just speed. SMS is paid per message, so a duplicate or a retry storm on SMS costs real money, and one bad loop can produce a large bill overnight. Put a hard daily spend cap on paid channels, and alert when spend runs ahead of the usual curve.

Channel fallback order. "Push, and if no device token, email; SMS only for security codes" is a product decision with cost and annoyance trade-offs. Make it data (a per-template policy), not code scattered across workers.

Multi-region. Running the pipeline in two regions survives a region outage, but now the dedupe store and idempotency keys must be shared or partitioned by user, or the same message can be sent from both regions. Most teams pin each user to a home region and fail over the whole pipeline, rather than sending from both at once.

Batching for throughput. Some provider APIs accept many recipients per call. Batching cuts per-call overhead a lot for blasts, but one bad recipient can fail the batch, and per-message retry and status get more complicated. Use it for bulk; keep transactional one at a time.

Ordering is usually unnecessary. "Order shipped" arriving before "order confirmed" looks odd but harms no one, and strict ordering across retries is expensive. (Queues §ordering covers what it would take.) If one user's messages must stay in order, key messages by user ID so they share a partition (one ordered slice of a queue or Kafka topic; messages with the same key always land in the same one).

Practitioner§12

Check yourself

Decide before you click. Every one is covered above: Q1 in §03 and §05, Q2 and Q3 in §06, Q4 in §07, Q5 in §08, Q6 in §09, Q7 in §09 and §10.

QUESTION 1 / 7
The 9 am blast

A 10M-user campaign is queued at 09:00 into the same push queue as everything else. The provider accepts 5,000 pushes a second. At 09:00:01 a user requests a password reset. Roughly how long can their push wait?

QUESTION 2 / 7
Retry or not?

Which provider response should the push worker not retry?

QUESTION 3 / 7
Why jitter?

10,000 sends fail together when a provider blips. Each retries with exponential backoff (1 s, 2 s, 4 s…) but no jitter. What goes wrong?

QUESTION 4 / 7
Crash after the send

A worker gets 200 from the SMS gateway, marks its delivery key (ntf_9:sms) “sent” in the dedupe store, then crashes before acking the queue message. What happens next?

QUESTION 5 / 7
Quiet hours

A marketing push is ready at 16:30 UTC on 31 Oct 2026. Quiet hours are 22:00–08:00 in the user's own zone. Who gets it now?

QUESTION 6 / 7
Sent vs delivered

The email provider returned 200 for every message all morning, but users say nothing arrived. Which signal would have caught it?

QUESTION 7 / 7
Push failover

APNs is having an outage. Login codes are normally sent by push. What's the realistic fallback?

Answered 0 / 7
Foundations§13

Cheat sheet

📮
Accept fast, send laterThe API checks the idempotency key and preferences, stores the message durably, and returns 202. Workers do the slow part.
🛤️
Separate linesOne queue per channel (a slow email provider can't block push) and per priority (a blast can't delay a password reset).
🔁
Retry the right thingsTimeouts, 429 and 5xx: retry with exponential backoff and jitter, capped. 400s: drop. Dead tokens: drop and delete.
🪦
Dead-letter the restOut of attempts or crashing every time → DLQ with an alert, redrive after a fix.
🧾
Dedupe every hopQueues deliver at least once. Key = notification + channel (+ device). Claim it with a lease, send, mark sent. Deterministic IDs for blasts; on-screen replacement (apns-collapse-id, Android notification tag) for push.
🌙
Don't spamFrequency caps and quiet hours in the user's time zone for marketing. Transactional is exempt.
🪣
Pace the providerA token bucket per provider, shared by all workers, with a reserved slice for transactional.
📬
Sent ≠ delivered200 means accepted. Delivered comes from status callbacks (or the app). Reconcile messages that go silent.
SituationDo this
Caller times out and retriesSame Idempotency-Key → return the original notification_id
Provider downCircuit breaker opens; fail over to a second provider (SMS, email) or another channel (push)
10M-user campaignOne API call → fan-out job → bulk lane, paced under the provider limit
Worker crashed mid-sendRedelivery + dedupe claim; the rare duplicate is softened by on-screen replacement for push
Message fails every timeAttempt cap → DLQ → alert
Hard bounce or spam complaintSuppression list, never send to that address again
Preferences store downMarketing fails closed, transactional fails open
Watch firstOldest-message age per lane, p99 API-to-provider time, delivered rate, DLQ growth

Numbers from §03 (approximate): 50M DAU → ~330M notifications a day, ~11,000/s at peak. A 10M blast at an illustrative 5,000/s takes ~33 minutes, so it must queue in its own lane.

Finished reading?Mark it complete to track your progress on a roadmap. Saved in this browser only.
Up next in System DesignDesign File Sync
Read →