Laravel Horizon Deep Dive: Queue Tuning, Supervisor Strategies, and Job Reliability
#laravel #horizon #queues #performance

Laravel Horizon Deep Dive: Queue Tuning, Supervisor Strategies, and Job Reliability

3 min read Mohamed Said Mohamed Said

Why the Default Horizon Config Will Bite You in Production

The horizon.php stub ships with a single default supervisor, auto balance mode, and maxProcesses: 1. That is fine for a demo. In production it means one slow job can stall your entire queue, memory leaks accumulate silently, and you have no visibility into which queue is the bottleneck.

This article walks through the decisions that actually matter.


Supervisor Strategies: Separate Concerns by Queue

Horizon's supervisor config is the most important lever you have. Group queues by their latency contract, not by convenience.

// config/horizon.php
'environments' => [
    'production' => [
        'supervisor-critical' => [
            'connection' => 'redis',
            'queue'      => ['critical'],
            'balance'    => 'simple',
            'processes'  => 10,
            'tries'      => 3,
            'timeout'    => 30,
            'memory'     => 256,
        ],
        'supervisor-default' => [
            'connection' => 'redis',
            'queue'      => ['default', 'notifications'],
            'balance'    => 'auto',
            'minProcesses' => 2,
            'maxProcesses' => 20,
            'balanceMaxShift'    => 5,
            'balanceCooldown'    => 3,
            'tries'      => 5,
            'timeout'    => 90,
            'memory'     => 256,
        ],
        'supervisor-heavy' => [
            'connection' => 'redis',
            'queue'      => ['exports', 'reports'],
            'balance'    => 'simple',
            'processes'  => 3,
            'tries'      => 2,
            'timeout'    => 600,
            'memory'     => 512,
        ],
    ],
],

simple vs auto balance: simple distributes processes evenly across queues in the list. auto watches queue depth and shifts workers toward the busiest queue. Use auto when queue depths are unpredictable; use simple when you need deterministic allocation.

balanceMaxShift and balanceCooldown prevent thrashing — Horizon will move at most 5 workers per 3-second cycle.


Long-Running Jobs: Timeout, Memory, and the Daemon Loop

The timeout value in Horizon config maps directly to the --timeout flag passed to queue:work. When a job exceeds this, the worker process is killed via SIGKILL — not a graceful exception. Your job must be idempotent.

class GenerateExportJob implements ShouldQueue
{
    public int $timeout = 540;   // job-level override
    public int $tries   = 2;
    public bool $failOnTimeout = true;

    public function handle(): void
    {
        // Checkpoint progress so a retry can resume
        Cache::put("export:{$this->exportId}:cursor", $this->cursor);

        // ... heavy work ...
    }

    public function retryUntil(): \DateTime
    {
        return now()->addHours(2);
    }
}

Set $failOnTimeout = true (Laravel 10+) so the job is marked failed rather than silently retried forever.


Memory Leak Mitigation

Horizon workers are long-lived PHP processes. Every static cache, event listener registered inside a job, or Eloquent model that holds a reference will accumulate.

// In a job that processes many models
public function handle(): void
{
    User::query()
        ->lazyById(200)
        ->each(function (User $user) {
            $this->process($user);
            // Detach the model from the identity map
        });

    // Force GC after heavy work
    gc_collect_cycles();
}

Set memory in the supervisor config to a value your server can sustain multiplied by maxProcesses. Horizon will restart a worker that exceeds its memory limit after the current job finishes.


Instrumenting Queues: Events and Metrics

Horizon emits first-class events. Hook into them for custom alerting without polling the dashboard.

// app/Providers/AppServiceProvider.php
use Laravel\Horizon\Events\JobFailed;
use Laravel\Horizon\Events\LongWaitDetected;

public function boot(): void
{
    Event::listen(LongWaitDetected::class, function (LongWaitDetected $event) {
        Log::warning('Long queue wait', [
            'connection' => $event->connection,
            'queue'      => $event->queue,
            'seconds'    => $event->seconds,
        ]);
        // Push to your alerting system
    });

    Event::listen(JobFailed::class, function (JobFailed $event) {
        Metrics::increment('queue.job_failed', [
            'job' => class_basename($event->payload['displayName']),
        ]);
    });
}

LongWaitDetected fires when a queue's wait time exceeds the threshold set in horizon.waits. Configure it per queue:

'waits' => [
    'redis:critical' => 5,   // alert after 5 seconds
    'redis:default'  => 60,
],

Takeaways

  • Separate supervisors by latency contract — never mix sub-second and multi-minute jobs in the same supervisor.
  • auto balance is not free — tune balanceMaxShift and balanceCooldown to prevent worker thrashing.
  • Set $failOnTimeout = true on long-running jobs so failures surface in the failed jobs table.
  • Memory limits are per-worker, not per-supervisor — size them against your actual job footprint.
  • Hook LongWaitDetected for proactive alerting instead of discovering queue backlogs after user complaints.
  • retryUntil beats $tries for time-sensitive jobs where absolute deadline matters more than attempt count.

Found this useful?

Frequently Asked Questions

3 questions
Q01 What is the difference between 'simple' and 'auto' balance modes in Horizon?
'simple' divides processes evenly across all queues in the supervisor list. 'auto' monitors queue depth in real time and shifts workers toward whichever queue has the most pending jobs, subject to balanceMaxShift and balanceCooldown limits.
Q02 How does Horizon handle a job that exceeds its timeout?
Horizon sends SIGKILL to the worker process — there is no exception thrown inside the job. Set `$failOnTimeout = true` (Laravel 10+) so the job is recorded as failed. Always design long-running jobs to be idempotent so a retry can safely resume.
Q03 Can I override the timeout at the job level rather than in the Horizon config?
Yes. Defining a public `$timeout` property on the job class overrides the supervisor-level timeout for that specific job. The lower of the two values wins, so ensure your supervisor timeout is at least as high as your longest job-level timeout.

Continue reading

More Articles

View all