Laravel Queues in Production: Horizon Tuning, Metrics, and Graceful Scaling
#laravel #horizon #queues #performance #production

Laravel Queues in Production: Horizon Tuning, Metrics, and Graceful Scaling

3 min read Mohamed Said Mohamed Said

Laravel Horizon in Production: Beyond the Defaults

Most teams install Horizon, push the default config, and move on. That works until traffic spikes, jobs pile up, and the dashboard turns red. This article focuses on the levers that actually matter: supervisor balancing strategies, metric-driven scaling decisions, and graceful shutdown patterns that prevent job loss.


Supervisor Configuration That Reflects Reality

Horizon's config/horizon.php ships with a single supervisor block. In production you almost always need multiple supervisors — one per logical job class or priority tier.

'production' => [
    'supervisor-critical' => [
        'connection' => 'redis',
        'queue'      => ['critical'],
        'balance'    => 'auto',
        'minProcesses' => 2,
        'maxProcesses' => 10,
        'tries'      => 3,
        'timeout'    => 30,
        'nice'       => 0,
    ],
    'supervisor-default' => [
        'connection' => 'redis',
        'queue'      => ['default', 'notifications'],
        'balance'    => 'simple',
        'processes'  => 4,
        'tries'      => 5,
        'timeout'    => 90,
        'nice'       => 10, // lower OS priority than critical
    ],
    'supervisor-bulk' => [
        'connection' => 'redis',
        'queue'      => ['bulk-exports'],
        'balance'    => 'auto',
        'minProcesses' => 1,
        'maxProcesses' => 3,
        'balanceMaxShift'  => 1,
        'balanceCooldown'  => 5,
        'timeout'    => 600,
        'nice'       => 15,
    ],
],

Key decisions here:

  • balance => auto lets Horizon shift processes between queues in the supervisor dynamically. Use it when queue depths fluctuate.
  • balanceMaxShift limits how many processes can be added or removed per rebalance cycle — prevents thrashing.
  • nice maps to the Unix process priority. Bulk jobs should yield CPU to critical ones.
  • Separate timeout per supervisor. A 600-second timeout on the critical supervisor would mask hung jobs.

Reading Horizon Metrics Without Guessing

Horizon stores rolling throughput and runtime snapshots in Redis. You can query them programmatically:

use Laravel\Horizon\Contracts\MetricsRepository;

$metrics = app(MetricsRepository::class);

$throughput = $metrics->throughputForQueue('default');  // jobs/min
$runtime    = $metrics->runtimeForQueue('default');     // avg ms

Pipe these into your observability stack (Datadog, Prometheus via a custom exporter, or even a simple scheduled command that writes to a log channel). Alert when throughput drops below your baseline or runtime climbs above your SLA threshold — not when the queue depth crosses an arbitrary number.


Handling Backpressure Without Losing Jobs

When producers outpace consumers, the naive fix is "add more workers." The better fix is to model backpressure explicitly.

// In a high-frequency dispatch path
use Illuminate\Support\Facades\Redis;

$depth = Redis::llen('queues:default');

if ($depth > 5000) {
    // Slow the producer, not just scale the consumer
    sleep(1); // or return a 429 to the upstream caller
}

Dispatch::dispatch(new ProcessWebhook($payload));

For HTTP-triggered jobs, returning a 202 Accepted with a Retry-After header is more honest than silently queuing millions of jobs that will take hours to drain.


Graceful Shutdown: The Part Everyone Gets Wrong

Horizon sends SIGTERM to workers when you deploy. Workers finish their current job and exit — but only if your jobs respect the signal. Long-running jobs that ignore termination signals will be killed mid-execution.

class ProcessLargeExport implements ShouldQueue
{
    public function handle(): void
    {
        foreach ($this->chunks() as $chunk) {
            if (app('queue.worker')->shouldQuit) {
                // Checkpoint state, re-dispatch remainder
                ProcessLargeExport::dispatch($this->remainingChunks());
                return;
            }

            $this->processChunk($chunk);
        }
    }
}

Set HORIZON_TERMINATE_WAIT (the --stop-when-empty equivalent) in your deploy script to give workers time to finish:

php artisan horizon:terminate
sleep 30  # match your longest expected job runtime
supervisorctl start horizon

Takeaways

  • Split supervisors by job priority and runtime profile, not just queue name.
  • Use balanceMaxShift and balanceCooldown to prevent process thrashing under auto balance.
  • Export Horizon metrics to your observability stack; alert on throughput and runtime, not raw depth.
  • Model backpressure at the producer — slowing dispatch is safer than unbounded queue growth.
  • Make long-running jobs checkpoint-aware so SIGTERM results in a safe re-dispatch, not data loss.

Found this useful?

Frequently Asked Questions

3 questions
Q01 When should I use `balance => auto` vs `balance => simple` in Horizon?
Use `auto` when a supervisor handles multiple queues with variable depth — Horizon will shift processes toward the busiest queue. Use `simple` for fixed-ratio allocation or single-queue supervisors where you want predictable process counts.
Q02 How do I prevent jobs from being lost during a Horizon restart on deploy?
Run `php artisan horizon:terminate` before starting the new release, and add a sleep in your deploy script equal to your longest expected job runtime. Ensure long-running jobs check `app('queue.worker')->shouldQuit` and checkpoint their state so they can safely re-dispatch remaining work.
Q03 Is it safe to run multiple Horizon instances across servers?
Yes. Each server runs its own Horizon process, and they coordinate through Redis. Ensure each server's `horizon.php` supervisor config reflects that server's role (e.g., bulk-export workers only on high-memory nodes) and that `APP_NAME` is unique per server if you want separate dashboard entries.

Continue reading

More Articles

View all