Designing a Webhook Delivery System for 10 Million Events a Day Maneshwar, the developer behind LiveReview, an AI code review tool, detailed the design of a webhook delivery system capable of handling 10 million events per day. The article outlines the pitfalls of naive implementations, such as synchronous HTTP calls in the request path, and advocates for the transactional outbox pattern to ensure durability and prevent head-of-line blocking. Hello, I'm Maneshwar, and I'm building LiveReview — a blast-radius aware AI code review built for your business-critical systems. Star us to help devs discover the project, give it a try, and share your feedback to help improve the product. Somebody on your team is going to say it, probably in a planning meeting, probably while looking at a Jira ticket that has three words in it. "Webhooks? That's just a POST request. Half a day." And they are not wrong about the POST request part. That is genuinely all a webhook is. Something happened on your side, your customer wants to know, you send them an HTTP request. Done. Then you ship it, and six weeks later you are on a call explaining to a customer why they missed 4,000 payment events during a window when their own server was down. So let's actually build this thing. Ten million events a day, which is roughly 115 a second on average and a lot more than that at peak. I'm going to build the naive version first and then break it, on purpose, over and over, until we end up somewhere that survives contact with real customers. The obvious one. Event happens in your request handler, you post it to the customer's URL, you wait for a 200 OK . python def on payment succeeded payment : db.save payment requests.post customer.webhook url, json=payment.to dict 🙃 return {"ok": True} This works beautifully in staging, where the "customer" is a webhook.site tab you have open in another window. Here is what it looks like in production. Your customer's endpoint is a Rails app on a small box that also runs their cron jobs. At 3pm their cron kicks off, their server starts taking eight seconds to respond, and now your request handler is sitting there holding a thread hostage waiting on somebody else's infrastructure. Your latency graph spikes. Your connection pool drains. Your own users, who have nothing to do with this, start seeing timeouts. And then the worst part: your request times out, the process moves on, and the event is gone. You never wrote it down anywhere. It existed only as a variable in a function that has now returned. The bug here is not "it was slow." The bug is that you made your availability a function of your customer's availability, and you did it in the hot path. Rule one of distributed systems, and honestly rule one of life: write it down before you try to do it. So the handler stops posting. It writes the event to a table, in the same transaction as the business change that caused it, and returns. That is the transactional outbox pattern https://microservices.io/patterns/data/transactional-outbox.html , and it is doing something subtle that is worth saying out loud. If you save the payment and then push to a queue, those are two systems and there is a gap between them. Crash in the gap, and you have a payment with no event. By putting the event row in the same database transaction as the payment, the two either both happen or both don't. No gap. BEGIN; INSERT INTO payments id, amount, status VALUES ... ; INSERT INTO webhook outbox customer id, event type, payload, status VALUES ..., 'payment.succeeded', ..., 'pending' ; COMMIT; Then a separate worker polls for pending rows and does the actual posting. Your handler is fast again. Your event is durable. If the delivery fails, it fails somewhere you can see and retry, instead of in a dead stack frame. But you have traded one problem for a sneakier one. You have one worker, or one pool of workers, pulling from one queue in order. Customer A's endpoint takes 10 seconds to time out. Every worker that picks up a Customer A job is parked for 10 seconds. Meanwhile Customers B through Z have events sitting behind them in the queue, perfectly deliverable, going nowhere. This is head-of-line blocking, and it is the noisy neighbour problem wearing a queue costume. One customer with a bad endpoint degrades everyone. Your worst customer sets the pace for all of them. The fix is fairness, and fairness needs somebody to enforce it. Put a dispatcher in front of the worker pool. Its whole job is to decide which event goes next , and it is not allowed to just take the oldest one. The dispatcher keeps a count of how many workers are currently busy with each customer. Customer A already has 3 in flight and their cap is 3? Skip them. Take the next customer's event instead. Come back to A later. This is per-tenant concurrency limiting, and it is the single highest-leverage thing in the whole design. Concurrency limits are also what Stripe uses https://stripe.com/blog/rate-limiters as a first-class rate limiting primitive, for exactly this reason: they bound damage rather than just counting requests. The effect is that Customer A's disaster is now capped. Three workers are stuck on them. Every other worker in the pool is happily serving everyone else. A slow customer now only degrades themselves, which is the correct place for the pain to land. If you want to go further, the dispatcher is also where you put weighted fairness, so your enterprise tier doesn't get starved by a free-tier customer emitting a million events an hour. Here is the routing logic, which is really the heart of the system: php flowchart TD A Pull next pending event -- B{Customer at