Laravel's queue system is deceptively easy to start with — dispatch a job, run a worker, done. The problems arrive with volume. On a platform processing around two million background jobs a day (email sends, webhook fan-out, report generation, AI enrichment), I learned that most queue incidents aren't Redis falling over. They're design decisions made back when the queue had one consumer and nothing was urgent.
One queue is a liability, not a simplification
The default trap is putting everything on the default queue. Then a nightly report job that takes 90 seconds per run lands 10,000 times in front of password-reset emails, and your 'fast' jobs inherit the latency of your slowest tenant. Queues are isolation boundaries. Segregate by latency class, not by feature: a critical queue for user-facing sends, a default queue for normal async work, and a long queue for anything that can legitimately take minutes.
// config/horizon.php
'environments' => [
'production' => [
'supervisor-critical' => [
'connection' => 'redis',
'queue' => ['critical'],
'balance' => 'auto',
'minProcesses' => 4,
'maxProcesses' => 24,
'tries' => 3,
'timeout' => 30,
],
'supervisor-long' => [
'connection' => 'redis-long',
'queue' => ['reports', 'exports'],
'balance' => 'auto',
'maxProcesses' => 6,
'timeout' => 900,
'memory' => 512,
],
],
],Note the separate Redis connection for long jobs. Under heavy load, a single Redis instance serving both cache and queues will happily let a burst of queue traffic evict your cache — or worse, let cache pressure slow queue polling. Split them early; it's a one-line config change before it becomes an incident.
Timeouts, retries, and the retry_after trap
The single most common Horizon misconfiguration I've fixed: a job timeout longer than the connection's retry_after. If timeout is 300 but retry_after is 90, Redis re-releases the job while the first worker is still running it, and now the job executes twice concurrently. Rule: retry_after must exceed your longest timeout on that connection, with margin. This is also why the long-job connection should be separate — it lets you set a generous retry_after there without delaying retries of fast jobs.
- Set an explicit timeout on every job class; the default hides your latency assumptions.
- Use backoff arrays — [10, 60, 300] — instead of hammering a struggling downstream at fixed intervals.
- Make handlers idempotent before raising tries above 1; retries without idempotency are data corruption on a timer.
- Cap maxExceptions separately from tries so a poison job can't burn all retries on the same deterministic failure.
Backpressure and fair scheduling across tenants
In multi-tenant SaaS, one tenant importing 400,000 contacts can starve everyone else's jobs. Horizon's auto-balancing helps between queues, not within one. What worked for us: per-tenant rate limiting at dispatch time using Redis::throttle, plus chunked dispatch — instead of enqueueing 400k jobs at once, enqueue a coordinator job that releases work in batches of a few thousand and re-dispatches itself. The queue depth stays bounded, Horizon's metrics stay meaningful, and no tenant monopolizes workers.
A queue is a promise about latency. If you can't say how long a job will wait, you haven't designed the queue — you've just deferred the outage.
Watch wait time, not throughput
Throughput graphs look healthy right up until they don't. The metric that predicts trouble is queue wait time — Horizon exposes it per queue, and it's the first thing to move when consumers can't keep up. We alerted at 30 seconds of wait on the critical queue and 10 minutes on long. Pair that with a failed-jobs alert that fires on rate of change rather than absolute count, and you'll catch a bad deploy in the first hundred failures instead of the first hundred thousand. Horizon gives you the dashboard for free; the discipline of deciding what 'unhealthy' means is on you.