Queues Aren't Just for Scale: Why I Move Slow Work Out of HTTP Requests

Queuing background jobs isn't just for big apps handling huge traffic. It's how I stop a single slow email or API call from taking down a form submission for everyone.

Queues Aren't Just for Scale: Why I Move Slow Work Out of HTTP Requests

A client once asked me why their signup form took nine seconds to load a "thanks for joining" page. Nothing complicated was happening. The form saved a row to the database, sent a welcome email, and pushed the new contact to a CRM through an API. Each of those steps took a second or two on a good day. On a bad day, the CRM's API was slow and the whole thing took twenty seconds, and some users just closed the tab, unsure if anything had happened at all.

This is the most common performance problem I see in business software, and it has nothing to do with scale. It's not about thousands of users. It's about one user waiting for three or four things to finish before they get a response, when only one of those things actually needs to happen before the page loads.

The request is not the place for slow work

When someone submits a form, clicks a button, or uploads a file, your server gets a request and has to send back a response. Anything that happens inside that window (an email sending, a PDF rendering, a call to a third-party API) makes the user wait for all of it. If any single step is slow or fails, the whole request can time out, and the user has no idea whether their action actually worked.

The fix is simple to describe and easy to underestimate: save what needs to be saved, respond immediately, and do the slow work afterwards in the background. This is what a queue is for. You put a job on it ("send this welcome email", "generate this PDF invoice", "sync this contact to the CRM") and a separate worker process picks it up and runs it whenever it can, completely detached from the person waiting on a page to load.

What actually belongs in a queue

In most of the systems I build, these are the jobs that never run inline:

  • Emails and notifications. SMTP providers occasionally hang or rate-limit you. That's not something a user submitting a form should ever feel.
  • PDF or report generation. Rendering a multi-page invoice or a monthly report can take real time, especially with charts or large datasets. Queue it, then notify the user or email them the file when it's ready.
  • Calls to external APIs. Payment providers, CRMs, shipping carriers, accounting software. You don't control their uptime or their response time, so you shouldn't let their slowness become your slowness.
  • AI jobs. Anything hitting an LLM API for a summary, a classification, or a generated document can take anywhere from one second to thirty. That variability alone is reason enough to move it off the request cycle.
  • Anything with a retry built in. If a step might reasonably fail and need trying again (a webhook delivery, a third-party sync), it needs to live somewhere that can retry it without the user sitting there.

A rough rule I use: if a step doesn't change what the user sees on the very next page, it probably doesn't need to happen before that page loads.

Queuing is easy. Handling failure is the actual work

Putting a job on a queue is the easy part. Frameworks like Laravel make that almost trivial. The part that actually takes thought, and the part I see skipped most often, is what happens when the worker fails.

Workers do fail. The third-party API times out. The PDF library throws an error on a weird character in someone's name. The server restarts mid-job. If you haven't planned for this, jobs quietly disappear and nobody notices until a customer asks why they never got their invoice.

A few things I build into every queue setup, no matter how small the project:

  • Automatic retries with backoff. A failed job gets tried again after a short delay, then a longer one, rather than hammering a struggling API repeatedly.
  • A failed jobs table (or equivalent). Jobs that exhaust their retries land somewhere visible, with the error message attached, rather than vanishing into a log file nobody reads.
  • Idempotency. If a job might run twice, running it twice shouldn't double-charge a customer or send two identical emails. This matters more than people expect, especially with payment and webhook-related jobs.
  • Alerting on real failure. Not every retry needs a notification, but a job that's failed three times and given up should tell someone, not just sit in a table waiting to be found by accident.

I've written before about what happens when this kind of thing goes missing entirely, in cases where nobody is checking the code that's meant to be handling these edge cases. Queue failure handling is exactly the kind of unglamorous logic that gets skipped when speed is the only thing being optimised for.

You don't need "scale" to benefit from this

The reason this matters even for small, low-traffic apps is that the slowest part of most systems isn't your own code, it's something you don't control. A payment gateway. An email provider. A CRM's API. Those things will be slow or flaky sometimes, regardless of how much traffic you have. A queue is what stops their bad day becoming your user's bad experience.

I built this pattern into Patch, a booking and payments app for trades, where reminder emails and recurring job scheduling run entirely in the background. A tradesperson taking a deposit on site shouldn't be staring at a spinner because a reminder email is being sent at the same moment. The payment confirms instantly. Everything else happens a second later, out of sight, and if it fails, it retries without anyone needing to resubmit anything.

If your app has a form, a button, or an action that "sometimes feels slow" for no obvious reason, it's worth checking what's actually happening inside that request. There's a good chance something in there doesn't need to be, and moving it out will fix the slowness for good rather than just making the server a bit faster.