Background Jobs and Queues: Moving Slow Work Out of the Request

How we handle slow work in web apps — email, exports, webhooks, image processing. When to queue it, how to design jobs that don't fail twice, and why retries need a strategy, not a prayer.

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.