Laravel · Laravel

Laravel Queue Scaling Patterns

How to scale Laravel queues past a single worker: choosing the right driver, sizing worker pools per queue, handling failed jobs, and avoiding the retry storms that take down a database.

John Kihiu12 min read

A Laravel queue that works fine in staging with one worker and breaks down in production isn't usually a code problem — it's a capacity planning problem nobody did. Scaling queues past a single worker means making explicit decisions about driver, queue segmentation, worker count per queue, and what happens when a job fails, instead of leaving all of it at Laravel's defaults.

Pick a driver that matches your failure tolerance

The database driver is fine for low volume and genuinely convenient — no extra infrastructure, jobs visible in a table you can query — but it doesn't scale past a few hundred jobs a minute before row locking on the jobs table becomes the bottleneck, since every worker polling for the next job takes a row lock to claim it. Redis removes that bottleneck (BRPOP-style blocking pop instead of polling with locks) and is the right default once you're past toy volume. SQS adds at-least-once delivery guarantees and horizontal scaling with no server to manage, at the cost of losing Laravel Horizon's dashboard, which only works with the Redis driver.

PHP · SEPARATE QUEUES BY WORKLOAD
// Don't run everything through the "default" queue — segment by workload
// so a burst of slow jobs can't starve fast, latency-sensitive ones.

ProcessPayment::dispatch($order)->onQueue('payments');   // fast, latency-sensitive
GenerateReport::dispatch($report)->onQueue('reports');   // slow, can wait
SendWelcomeEmail::dispatch($user)->onQueue('emails');    // fast, high volume

// horizon.php — separate worker pools per queue, sized independently
'environments' => [
    'production' => [
        'supervisor-payments' => [
            'connection' => 'redis', 'queue' => ['payments'],
            'minProcesses' => 2, 'maxProcesses' => 10, 'balance' => 'auto',
        ],
        'supervisor-reports' => [
            'connection' => 'redis', 'queue' => ['reports'],
            'minProcesses' => 1, 'maxProcesses' => 3, 'timeout' => 600,
        ],
    ],
],

One slow queue should not starve a fast one

The most common scaling mistake is running every job type through a single `default` queue with a fixed pool of workers. A burst of slow report-generation jobs fills every worker slot, and password-reset emails — which should complete in milliseconds — sit queued behind them for minutes. Segmenting queues by latency sensitivity and workload, then sizing worker pools per queue independently (Horizon's `supervisor` config, or separate `queue:work` processes with `--queue=` flags), is the fix. Fast queues get a small, cheap pool that's rarely saturated; slow queues get a pool sized for their actual concurrency needs and a longer `--timeout`.

Horizon's auto-balancing needs real headroom to work

Horizon's balance => 'auto' shifts workers between supervisors based on queue backlog, but it can only redistribute workers you've actually provisioned. If every supervisor is already maxed out, auto-balancing has nothing to redistribute — it's a load-balancing feature, not a substitute for capacity planning.

Failed jobs and retry storms

The default retry behavior — retry a fixed number of times with no backoff — is dangerous at scale. If a downstream dependency (a payment gateway, a third-party API) goes down, every queued job hitting it fails and retries near-instantly, and the retry volume itself can be enough to keep the downstream service from recovering. Exponential backoff via `$job->backoff()` returning an increasing array of delays, combined with a sane `$tries` limit and a `failed()` method that actually does something (alert, log to a dead-letter queue), turns a retry storm into a controlled degradation instead of a cascading outage.

PHP · BACKOFF AND FAILURE HANDLING
class ChargeCustomer implements ShouldQueue
{
    public $tries = 5;

    public function backoff(): array
    {
        return [10, 30, 60, 300, 900]; // seconds, increasing
    }

    public function failed(Throwable $exception): void
    {
        // After all retries exhausted — this is not "log and forget"
        Notification::route('slack', config('services.slack.ops_webhook'))
            ->notify(new PaymentJobFailed($this->order, $exception));
    }
}

Worker count is a database connection problem too

Every queue worker process holds its own database connection pool. Scaling from 4 workers to 40 because Horizon's autoscaler decided the backlog justified it can silently exhaust your database's max_connections if nobody accounted for it — the queue scales, the database doesn't, and you trade a queue backlog for a "too many connections" outage instead. Worker count needs to be planned against database connection limits, not just against queue throughput targets.

Long-running workers leak memory in PHP too

A queue:work process that runs for days without restarting can accumulate memory from framework-level caching and third-party SDKs that weren't designed for long-lived processes. Set --max-jobs or rely on Horizon's automatic worker recycling so processes restart periodically — treat a worker that's been alive for a week with rising RSS as a bug, not a feature.

Wrapping up

Scaling Laravel queues is capacity planning wearing a queue driver's clothes: pick Redis or SQS once volume outgrows the database driver, segment queues by latency sensitivity so slow jobs can't starve fast ones, add backoff to retries so a downstream outage doesn't become a retry storm, and size worker pools against your database's connection limit, not just against queue backlog.

John Kihiu
Acumatica ERP Developer · Laravel Engineer

Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.