---
title: "Why Do Laravel Queues Slow Down in Production?"
description: "Laravel queue workers are flawless locally and slow in production. The five most common reasons developers overlook."
url: https://sade.dev/en/notes/laravel-queue-production-slowdown/
lang: en
author: "Muhammet Şafak"
published: 2026-05-03
updated: 2026-09-06
section: Note
tags: ["laravel","queue","production","redis"]
summary: "A queue that is slow in production usually has five causes underneath Laravel, not inside it: a connection opened per job, I/O in the job with no explicit timeout, a worker count that ignores the core count, no periodic restart against memory leaks, and priorities piled onto one queue. All five are measurable, and measuring is the point: a slow queue should be something you measured, not something you assumed."
---

# Why Do Laravel Queues Slow Down in Production?

> A queue that is slow in production usually has five causes underneath Laravel, not inside it: a connection opened per job, I/O in the job with no explicit timeout, a worker count that ignores the core count, no periodic restart against memory leaks, and priorities piled onto one queue. All five are measurable, and measuring is the point: a slow queue should be something you measured, not something you assumed.

"This queue worked locally" has been said in the same tone for years. It works in production too — just not at the speed you expected. The real reasons usually live outside Laravel.

## 1. No connection pool for the queue storage

Say you're using Redis as your queue storage.

By default, `phpredis` or `predis` opens a new connection per worker process, not per job — `queue:work` is a single long-lived process and Laravel's `RedisManager` caches the connection it resolves. Where a new process is spawned for every job (`queue:listen`, PHP-FPM requests), that becomes a new connection per job: one TCP handshake of latency each, plus the occasional `ECONNRESET`.

The fix: `persistent => true` in `config/database.php` (for phpredis) — Laravel passes the same option through to `RedisCluster`, so a clustered setup is covered too. Keep the connection open when you start the worker:

```php
'redis' => [
    'options' => [
        'cluster' => env('REDIS_CLUSTER', 'redis'),
        'prefix' => env('REDIS_PREFIX', ''),
        'persistent' => true,
    ],
],
```

_**Note:** On the RabbitMQ side, the main optimization point is not the persistent socket flag but connection lifecycle management. Instead of opening a connection per job, use a long-lived AMQP connection per worker process, reuse channels, and tune heartbeat/read-write timeout values to match job durations._

## 2. Synchronous I/O blocking inside the job

An `Http::get()` call can sit waiting on a 30-second timeout. That job holds that worker. 10 workers, 10 slow upstreams — the rest of the queue stalls.

Two rules:

- Every HTTP/SQL call gets an **explicit timeout**. Not the default 30 seconds, 5.
- Work that has to wait belongs on a separate `delay`-ed queue (e.g. a `slow` queue for webhook retries, fast work on the `default` queue).

## 3. Wrong Supervisor `numprocs`

A single queue worker is a single PHP process. A single PHP process uses a single CPU core. Running 1 worker on a 4-core server means leaving `nproc * 0.75` idle.

A typical rule: `numprocs = nproc` (CPU-bound) or `numprocs = 2 * nproc` (I/O-bound). Measure every app.

```ini
[program:laravel-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/app/artisan queue:work redis --queue=default --sleep=1 --tries=3 --max-time=3600
autostart=true
autorestart=true
numprocs=4
user=www-data
redirect_stderr=true
stdout_logfile=/var/log/laravel-worker.log
stopwaitsecs=3600
```

`stopwaitsecs=3600` matters — it stops Supervisor from cutting a long job short during a restart.

## 4. You're not restarting on memory leaks

Long-running PHP processes accumulate memory. With `--max-time=3600` or `--max-jobs=1000`, workers should terminate themselves periodically and be restarted by Supervisor. Otherwise:

- The worker eats 8 GB of RAM.
- The OOM killer kills it.
- It's unclear which job it cut off mid-flight.

## 5. Using a single queue for job batches

Throw high-volume "send notification" jobs and a handful of "process payment" jobs onto the same queue and you get:

- 50,000 notifications clog the queue for 30 minutes.
- The payment job waits.
- The customer writes in saying "my payment didn't go through".

**Prioritize:** put important work on separate queues and have the worker listen in order with `--queue=payments,default,low`.

## The metrics you need to watch

Three things are enough:

- **Queue length** (Redis: `LLEN` on the queue list — delayed and reserved jobs sit in separate sorted sets, so add their `ZCARD`).
- **Job duration p95** (your own instrumentation — Horizon's metrics dashboard gives throughput, wait time and *average* runtime, not percentiles).
- **Failed job count** in the last 5 minutes.

Put these three on a dashboard and have them alert when they cross a threshold.

---

90% of queue slowness is something on this list. The remaining 10% are the real bottlenecks (database, external API) — and to find those you need the right measurement. In production, "the queue is slow" shouldn't be a hypothesis; it should be something you measured.

## Frequently asked

**How many queue workers should Supervisor run?**

One worker is one PHP process on one CPU core, so a single worker on a four-core box leaves most of the machine idle. The rule of thumb is numprocs equal to the core count for CPU-bound work and twice the core count for I/O-bound work — then measure the specific application rather than trusting the rule.

**Why does stopwaitsecs matter in the Supervisor config?**

Supervisor waits stopwaitsecs seconds for a process to stop before killing it, and the default is 10. A worker halfway through a long job would be cut short by that. Set it above your longest job duration so a restart lets the job finish.

**Which measurements tell you the queue is genuinely slow?**

Three: queue length, the p95 of job duration, and the failed job count over the last five minutes. On Redis the queue length is the list LLEN plus the ZCARD of the delayed and reserved sorted sets, and p95 needs your own instrumentation — Horizon reports throughput and wait times, not percentiles.


## Sources

- [Laravel queues: long-lived workers, --max-jobs, --max-time and queue priorities](https://laravel.com/docs/12.x/queues) — Laravel
- [Laravel Redis: persistent is a supported PhpRedis connection parameter](https://laravel.com/docs/12.x/redis) — Laravel
- [Supervisor program settings: numprocs and stopwaitsecs, which defaults to 10 seconds](https://supervisord.org/configuration.html) — Supervisor
- [Laravel Horizon: the metrics dashboard reports throughput and wait times](https://laravel.com/docs/12.x/horizon) — Laravel
