Skip to main content
All posts
· 5 min read

Laravel Horizon Workers Idle While Jobs Pile Up? 6 Causes (And Fixes)

Horizon shows workers as running while 2,000 jobs pile up untouched. Here are the 6 real causes — and how to fix each one fast.

You open the Horizon dashboard and see five idle workers. You check the queue and see 2,000 pending jobs. The workers are right there. The jobs are right there. Nothing is happening.

This is one of the more frustrating Horizon failure modes because everything looks like it should be working. The processes exist, the queue has jobs, and yet throughput is zero.

Queue name mismatch

The single most common cause. Workers are listening on default, but jobs are being dispatched to high or emails or a queue name you added last week. Horizon workers only process jobs from queues they’re configured to listen on. A config-environment mismatch can also cause the wrong supervisor config to take effect — for example, production servers running the staging environment block and listening on completely different queues.

Check your Horizon config:

// config/horizon.php
'environments' => [
    'production' => [
        'supervisor-1' => [
            'connection' => 'redis',
            'queue' => ['default'],  // Only listens on 'default'
            'processes' => 5,
        ],
    ],
],

Now check where your jobs are actually going:

// In your job class
public $queue = 'invoices';  // This goes to 'invoices', not 'default'

Or dispatched explicitly:

ProcessInvoice::dispatch($invoice)->onQueue('invoices');

If no supervisor is configured to listen on invoices, those jobs sit forever. The fix is either changing the job’s queue or adding a supervisor that listens on it:

'supervisor-2' => [
    'connection' => 'redis',
    'queue' => ['invoices'],
    'processes' => 3,
],

After changing horizon.php, restart Horizon:

php artisan horizon:terminate

Supervisor (the process manager) will restart it automatically with the new config.

Connection mismatch

Less obvious than queue name issues. If your job specifies a connection that differs from what the supervisor is configured to use, the job lands in a queue that no worker is watching.

// Job dispatched on 'redis-long-running' connection
ProcessReport::dispatch($report)->onConnection('redis-long-running');
// But the supervisor uses the default 'redis' connection
'supervisor-1' => [
    'connection' => 'redis',  // Different from 'redis-long-running'
    'queue' => ['default'],
],

These are separate Redis connections with separate queue lists. A worker on redis will never see jobs on redis-long-running. Check your config/queue.php connections and make sure your Horizon supervisors match.

The auto-balancer moved all processes away

Horizon’s auto balance strategy redistributes worker processes across queues based on queue depth. If one queue has zero jobs and another has thousands, the balancer moves workers to where the work is.

The problem arises when the balancer runs, moves workers away from a queue, and then new jobs arrive on the now-empty queue. The balancer takes a few seconds to react, and during that window the queue has jobs but zero workers.

In extreme cases, the balancer can oscillate: moving workers to queue A, jobs arrive on queue B, moving workers to B, jobs arrive on A.

Check current worker distribution:

redis-cli hgetall "horizon:supervisor-name:workers"

If a supervisor shows zero processes allocated to a queue that has pending jobs, the balancer is the likely cause. You can switch from auto to simple balancing if the oscillation is causing problems:

'supervisor-1' => [
    'balance' => 'simple',  // Fixed distribution instead of auto
    'processes' => 5,
    'queue' => ['default', 'emails'],
],

Or set minimum processes per queue using balanceMinProcesses:

'supervisor-1' => [
    'balance' => 'auto',
    'minProcesses' => 1,
    'maxProcesses' => 10,
],

Workers are stuck on a long-running job

If a single job takes 30 minutes to complete and you have 5 workers, one of those workers is occupied the entire time. If several long-running jobs arrive at once, all workers can be busy simultaneously, and new jobs queue up behind them.

Horizon shows these workers as “processing” rather than “idle,” but the effect on queue throughput is the same: jobs pile up.

Check what each worker is currently doing:

use Laravel\Horizon\Contracts\WorkloadRepository;

$workload = app(WorkloadRepository::class)->get();

foreach ($workload as $queue) {
    echo $queue->name . ': ' . $queue->length . ' pending, '
        . $queue->processes . " workers\n";
}

The fix depends on the situation. For legitimately long jobs, move them to a dedicated queue with its own supervisor so they don’t block shorter jobs:

// config/horizon.php
'long-running' => [
    'connection' => 'redis',
    'queue' => ['reports'],
    'processes' => 2,
    'timeout' => 3600,
],

For jobs that shouldn’t be taking that long, add a timeout to the job class:

public $timeout = 120; // Kill after 2 minutes

Redis memory pressure causing silent drops

When Redis runs out of memory and has maxmemory-policy set to something other than noeviction, it starts evicting keys to make room. Queue keys can be evicted, which means jobs disappear from the queue without being processed. Workers appear idle because the queue is technically empty from Redis’s perspective, even though jobs were never completed.

Check Redis memory usage:

redis-cli info memory | grep used_memory_human
redis-cli config get maxmemory-policy

If the policy is allkeys-lru or volatile-lru, Redis will evict queue keys under memory pressure. For queue workloads, set the policy to noeviction so Redis returns an error on writes instead of silently dropping data:

redis-cli config set maxmemory-policy noeviction

Monitoring for idle-but-jobs-pending with Crontinel

The core problem here is that Horizon’s dashboard shows worker count and queue depth as separate numbers. It doesn’t alert you when the combination is wrong: workers exist, jobs exist, but throughput is zero.

Crontinel correlates these metrics automatically. If queue depth is growing while worker throughput is flat, it fires an alert. That catches queue name mismatches, connection mismatches, stuck workers, and balancer issues, all from the same signal.

composer require crontinel/laravel
php artisan crontinel:install

The features page details how throughput correlation works across multiple supervisors and queues.

See also

blog
Laravel Cron vs Queue Monitoring — What's the Difference?

Generic uptime monitors miss scheduler failures. Queue depth monitors miss Horizon supervisor death. Here's why you need both, and what each one actually covers.

blog
Laravel Queue Worker Died: How to Detect and Recover

Queue workers die quietly. OOM kills, segfaults, and restart loops leave no obvious trace while jobs pile up. Here's how to detect a dead worker before your queue backlog becomes a customer problem.

blog
Why Generic Uptime Monitors Miss Laravel Queue Failures

Your uptime monitor shows green while your queue depth climbs to 5,000 and customers wait. Here's why HTTP pings can't see Laravel queue failures, and what actually works.

use cases
Detect When Laravel Horizon Workers Are Running But Not Processing Jobs

Your Horizon dashboard shows active supervisors and workers, but jobs sit in the queue for minutes. Here is how to catch worker starvation before it turns into a production incident.