← All writing

March 10, 2026

Why Every Slow Operation Should Go Through a Queue

A practical case for moving email, webhooks and anything else that can fail slowly off the request path and into a Redis + BullMQ queue — and the failure modes that make the extra moving part worth it.

Node.jsRedisBullMQBackend

The symptom is always the same shape. A request handler that used to return in 80ms starts taking 900ms, then occasionally times out completely. Nobody changed the database query. What changed is that somewhere in that handler, someone added await sendEmail(...) or await notifyWebhook(...), and now the user is waiting on a third-party API that has opinions about how fast it feels like responding today.

The fix is almost never "make the email service faster." It's "stop making the user wait for it at all."

The rule I use

If an operation can fail for reasons that have nothing to do with the request — a mail provider having a bad five minutes, a webhook endpoint being temporarily down, a PDF renderer choking on a weird input — it doesn't belong in the request/response cycle. It goes on a queue.

On a checkout system I built across five WordPress storefronts, this was transactional email: order confirmations, shipping updates, password resets. Originally these were awaited inline. Under normal load it was fine. The moment the email provider had a slow afternoon, checkout latency spiked right along with it, because the user's "place order" click was blocked on a network call to a system we didn't control and couldn't see into.

Moving it to Redis + BullMQ meant the checkout handler's job became: validate the order, write it to the database, enqueue an order.confirmed job, and return. The email itself happens a few hundred milliseconds later, off the critical path, with its own retry policy.

What you actually get

Retries with backoff, for free. BullMQ's built-in retry/backoff means a transient failure — provider returns a 503, DNS hiccup, whatever — doesn't need custom handling. Configure attempts and backoff once, per queue, and stop writing try/catch retry loops by hand.

A place for failures to be visible. A failed job doesn't vanish into a swallowed exception in a request handler. It sits in the queue's failed state, with the payload and the error, until something looks at it — a dashboard, an alert, a human. That's a real audit trail, not a log line scrolled past at 2am.

Idempotency becomes unavoidable, in a good way. Once a job can be retried, it has to be safe to run twice. That forces a decision you should be making anyway: does sending the same confirmation email twice matter? (Usually not.) Does double-charging a card matter? (Very much yes — so payment capture gets an idempotency key, checked before the charge runs, not after.) Queues don't create this problem; they just make you solve it instead of getting lucky.

Where I wouldn't do this

Not every synchronous call needs a queue. If the operation genuinely has to complete before you can tell the user whether it worked — validating a coupon code, checking real-time inventory before confirming a sale — a queue adds latency and complexity for no benefit. The test isn't "is this slow," it's "does the user need to know the result before the response goes back." If yes, keep it synchronous and make it fast. If no, get it off the request path.

The extra moving part is a real cost — another service to run, another failure mode to reason about, a job payload schema to version. It's worth paying the moment "wait for the email to send" and "tell the user their order went through" stop being the same requirement.

Need this built?