Background Jobs and Queues: Moving Slow Work Out of the Request
Every web app eventually hits the same wall: something can't finish inside a normal request. Sending a thousand emails, generating a CSV export, resizing images, hitting a slow third-party API. Do it synchronously and your users stare at a spinner while the database connection is held hostage. Move it to a queue and the request returns in 50 milliseconds while the work happens in the background. Here's how we approach queues in production.
The rule: anything slower than 300ms gets queued
That's the threshold we use, roughly. If a request needs to do real work — an API call that can take seconds, a heavy import, a PDF generation — it doesn't belong in the request lifecycle. The page should respond instantly, and the user should get confirmation later (an email, a status field, a notification) when the work is done.
The classic example is email. Sending it inline means your request speed is whatever the mail provider decides it is today. Queued, it's a job that retries on failure and never blocks a checkout page.
// Laravel — dispatch a job instead of doing the work inline
SendInvoiceJob::dispatch($invoice);
Design jobs to be retried
A job will fail at some point: the mail server is down, the webhook endpoint returns 500, the file is temporarily locked. So design every job as if it might run twice — because it will.
- Make jobs idempotent. If a job runs twice, the second run should be harmless. Guard with a check:
if ($invoice->email_sent_at) return; - Keep jobs small and focused. One job, one responsibility. "Process order" becomes
ChargePaymentJob,SendReceiptJob,UpdateInventoryJob. Small jobs are easier to retry independently and easier to reason about. - Pass IDs, not objects. Serializing a whole Eloquent model into the queue means the job runs against stale data. Pass the ID and re-fetch inside the job — the job always sees the current state.
- Add a sensible timeout. A job that downloads a file from an external service needs a generous timeout. A job that writes one row doesn't. Timeouts are per-job, not global.
// Pass the ID, re-fetch, and retry until the remote service behaves
class SyncWebhookJob implements ShouldQueue
{
public int $timeout = 120;
public function handle(): void
{
$order = Order::find($this->orderId);
if (! $order || $order->synced_at) return;
// ... call the remote service
}
}
Retries: have a strategy, not a prayer
The default retry policy — try again in a few seconds, three times, give up — is fine for transient glitches and wrong for everything else. A rate-limited third-party API needs exponential backoff. A bug in your own code will fail identically on every retry, so retrying it five times is just noise.
- Exponential backoff for external services: 10s, 60s, 5m, 30m. Give the remote system time to recover.
- A hard cap on attempts. After N failures, stop and mark the job failed. Nobody wants a job that retries forever.
- Send failed jobs somewhere visible. A "failed jobs" list or a notification on the Nth failure — a job that silently dies is worse than one that loudly fails, because you'll learn about it from a customer.
Don't over-engineer the transport
You don't need a dedicated infrastructure story to start. Laravel's database queue works and is honest about its limits. Redis is a step up when you need speed and volume. But the queue driver matters far less than job design — a well-designed job on a database queue beats a sloppy one on a fancy cluster.
What matters is that the queue worker runs in production (a supervisor or systemd service), that failures are visible, and that you can retry a failed job with one command. If a queue is set up but nobody ever looks at the failed-jobs screen, it's not a queue, it's a hole where work goes to disappear.
The payoff is user experience
Well-designed queues feel invisible: the form submits instantly, the confirmation arrives a minute later, and the server never bogs down during a newsletter blast. The discipline — threshold, idempotency, retry strategy — is small, but it's the difference between an app that feels fast because it's lucky and an app that's fast by design.