Star
Engineering

Why notification systems are a nightmare for engineers

A shop sends you five messages after you place an order. For each one I found a company that got it wrong in production, and below are those incidents and the techniques that prevent them.

The 'This is fine' dog sitting in a burning room. Text: Duplicate receipts. 2am pushes. Provider down. / Your notification system
Every team that wrote sendEmail() and called it done.

I'll follow one order. Ana lives in Lisbon. On Tuesday afternoon she orders a ceramic lamp from Halden, a small homewares shop with a warehouse in Ohio. Over the next week, Halden sends her five messages:

  1. ReceiptSent twice · Salesloft, Zoho
  2. ShippedArrives in the middle of the night · Asana
  3. Out for deliveryThe rate limiter breaks · Buttondown
  4. DeliveredFor an order nobody placed · Starbucks
  5. Review requestA test reaches real people · HBO Max, Everbridge
The sequence. Five messages, and the incident that shows how each one goes wrong.

The build list (on the left, or the counter at the bottom on a phone) fills up as you read with everything you'd have to write to get these messages right.

01The receipt · Tuesday, 3:12 pm in Lisbon

The receipt that arrives twice

Checkout asks for the receipt. The request times out, so the code retries. But a timeout only means you didn't hear back. The email may already have gone out.

Two identical Spider-Men pointing at each other. Text: Receipt #8812 / Receipt #8812 (the retry)
Ana's inbox after one timeout and one retry.
Without a key. The retry looks like a new email.
With order-8812-confirmed. The retry is recognised.
Figure 1. Same timeout, same retry. Green is the first attempt, amber is the retry.

An idempotency key built from the event (order-8812-confirmed, not a random UUID) turns the retry into a no-op. A send lock, such as a Redis SET NX with a lease longer than the send timeout, stops two workers sending the same message. Saved step results let a restarted worker skip what it already did.

None of that would have saved Salesloft. If the provider reports a failure that didn't happen, every sender retries. Only the provider can close that gap, by accepting your key.

02Shipped · 10:40 pm in Ohio, 2:40 am in Lisbon

"Your order has shipped," at 2:40 in the morning

The night shift in Ohio scans the box in the evening, which is the middle of the night in Lisbon, and Ana's phone is on her bedside table.

"Your order has shipped"held, not dropped
"Card flagged for fraud" · criticalsent now
Figure 2. Ana's day in Lisbon time. The shaded area is her quiet hours, 23:00–08:00. They cross midnight, so the check is "after 23:00 or before 08:00".

Store an IANA zone (Europe/Lisbon), not a UTC offset, because clocks change twice a year. Park the message in a durable scheduler so it survives a restart. At 08:00, check her preferences again in case she unsubscribed overnight. Fraud alerts need a critical bypass.

03Out for delivery · Friday, 8:00 am, every customer at once

Out for delivery, and so is everyone else's

Every European order leaves the depot at once, so thousands of pushes want to go out in the same minute. Providers cap how fast you can send, so you add a rate limiter, and the limiter is new code that can have its own bugs, as Buttondown found.

A common bug is telling every throttled message to come back in one second, so they all return at once. Jitter fixes it: each waits a random time within the window. The limiter must also be shared across processes, usually in Redis, or each worker stays under the limit while together they blow through it.

Everyone retries at +1s

The jam forms again every second.

Each picks a random time

A queue that moves at the speed the provider allows.

Figure 3. 5,000 messages against a limit of 100 a second. Green got through, red was turned away. Waiting in line doesn't count as a failed attempt.

When a provider is actually down, the message moves along a chain until something works:

Figure 4. Provider failover, then channel fallback. If Ana has turned texts off, the chain stops: her choice wins.

A circuit breaker skips a failing provider for a while instead of waiting on a timeout for every message. Priority lanes keep a newsletter from holding up a password reset, and a per-user cap limits the damage when your own code loops.

04Delivered · Friday, 11:26 am in Lisbon

"Delivered," for a parcel that wasn't

The courier scans the parcel and calls Halden's webhook, and Halden sends whatever the webhook says. If the event is wrong, the message is wrong too. A notification system can't catch that, but it can tie each message to the right event for the right order by running the whole sequence as one workflow:

Receipt email
wait for order.shipped
Shipped email
wait for order.out_for_delivery
Out for delivery push → sms
wait for order.delivered
Delivered push + email
wait 3 days, unless a return starts
Review request email
order.delivered #8813
✕ not this order, ignored
order.delivered #8812
✓ matched, continue
Figure 5. One workflow per order. Each step is saved before the next one runs, so a deploy on Thursday picks up exactly where it left off.

Events wake only the matching order, "delivered" can't go out before "shipped", and if Ana cancels, nothing more is sent. That takes a workflow engine with durable waits and cancellation, a project in its own right.

05Review request · Monday, three days later

The email that should never have gone out

Marketing sends review requests in bulk, to all of last month's customers at once. That's also how the most famous notification incident happened.

A sweating man deciding between two identical red buttons. Text: Send test email / Send to all subscribers / Me, with the production API key
Both buttons look the same until one of them is in the news.

Here are the guards between a bulk send and an inbox:

Segment resolvedeveryone you tagged8,420
Opted out of "reviews"topic preference−612
Suppressedbounced, unsubscribed, spam−97
In quiet hourssent at 08:00 their time1,340
Deliveredwith one-click unsubscribe7,711
And a test send: staging key→ sandbox project→ log-only transport→ 0 real inboxes
Figure 6. One bulk send through every guard, with example numbers for the segment customers-2026-09.

Topic preferences let Ana mute review requests and keep receipts. Gmail and Yahoo require one-click unsubscribe on bulk mail. A suppression list fed by bounce and complaint webhooks blocks every send, critical ones included. Delivery logs and campaign tracking show who got what, and a kill switch stops a bad send halfway.

All of those check whether Ana wants the message, and none of them checks whether you meant to send it. HBO Max and Everbridge both had a test reach a live channel. What helps there is keeping test sends on staging credentials that can't reach a real inbox or phone.

22The bill

Why it's a nightmare

Five order messages need all of this, plus a dead-letter queue and alerts so you can answer "why didn't Ana get her email?"

  • idempotency keys
  • per-send lock
  • saved step results
  • time-zone quiet hours
  • durable scheduler
  • re-check before send
  • critical bypass
  • shared rate limiter
  • jitter
  • circuit breakers
  • provider failover
  • channel fallback
  • priority lanes
  • per-user cap
  • workflow engine
  • cancellation
  • topic preferences
  • one-click unsubscribe
  • suppression list
  • delivery logs
  • kill switch
  • campaign tracking

22 pieces behind five order messages.

Each piece is manageable alone, but they interact. The quiet-hours check has to respect the suppression list, the retry has to respect the idempotency key, and the workflow has to survive a deploy halfway through. Most of the companies in this article shipped one of these bugs themselves.

Oftentimes things that seem like they should be simple end up being way more complicated when you get into the details.

Sophie Alpert, on Slack's notification logic · "Everyone is wrong about that Slack flowchart"
About notifkit

What I'm building

notifkit, the open-source library I've been building, is built around this list. It runs next to your own Postgres and Redis. It's a young project and I'm working on it actively, so if something here is missing or breaks for you, open an issue at github.com/devkitshq/notifkit and I'll look at it.