Laravel Horizon Queue Balancing: Idle Workers vs. Starved Queues
· Updated · 10 min read · Boring Observability
Verified against Laravel 13 · Horizon 5.x
Every Horizon supervisor forces a choice that rarely gets discussed out loud: turn balancing on and pay for a floor of idle workers, or turn it off and let a busy queue starve the ones beneath it. The more queues you run, the sharper that choice gets — and a lot of teams discover it only after a flood on one queue quietly stalls everything else.
This article walks through why the trade-off exists, why running many small queues makes it worse, and how Skyline's weighted queues collapse the two bad options into one good one.
Key takeaways#
- Balancing on (
'auto'or'simple') gives every queue its own worker pool. Nothing starves, but you pay a permanent floor of resident workers that scales with your queue count. - Balancing off (
false) runs one efficient shared pool with no idle floor — but the queue list becomes a strict priority order, so a flood on one queue starves every queue below it. - Both costs scale with the number of queues, which penalises exactly the teams who split work into many queues to get per-queue pausing, draining and visibility.
- Skyline's
queueWeightskeeps the shared pool of unbalanced mode but checks queues proportionally (3 : 2 : 1) rather than in strict order — important work gets the lion's share, nothing drops to zero.
Why you end up with thirty queues in the first place#
A queue is the smallest unit Horizon lets you observe and operate on. Pausing is per queue. Draining is per queue.
So the instant you want real operational control — "show me how exports is doing," "pause webhooks
while the downstream API is down," "empty the reindex backlog without touching payments" — the natural move
is to split work into more, narrower queues. (Skyline extends that control further —
pausing and resuming a single queue from the dashboard rather
than the whole supervisor.)
Taken to its conclusion, that's how a healthy app ends up with thirty queues: payments, emails,
webhooks-stripe, webhooks-github, exports, imports,
reindex, thumbnails, and on down the list. Each one buys you a clean dashboard row and an
independent pause/drain switch. That granularity is genuinely valuable.
More queues mean more operational control — and a bigger bill from whichever balancing mode you pick.
Because the number of queues is exactly the dimension both balancing trade-offs scale with. Here's how.
Option A: balance => 'auto' — a floor of idle workers#
With balance => 'auto' (or 'simple'), Horizon allocates worker processes per queue. The
auto balancer scales each queue's worker count up and down with its workload, but it is bounded on the low end by
minProcesses, which defaults to 1:
'supervisor-1' => [
'connection' => 'redis',
'queue' => ['payments', 'emails', 'exports', /* ...27 more */],
'balance' => 'auto',
'minProcesses' => 1,
'maxProcesses' => 40,
],
The guarantee that minProcesses buys you is real: every queue always has at least one worker watching it, so
a flood on exports can't prevent payments from being serviced. Each queue is isolated. That's
the upside, and for many setups it's the right default.
The cost is just as real. One minimum worker per queue across thirty queues is a permanent floor of thirty worker processes — booted, resident, and idle most of the time, because most queues are empty most of the time. Each PHP worker is a full framework boot holding tens of megabytes of RAM whether or not it ever picks up a job.
With balancing on, your minimum process count is your queue count. Thirty queues means thirty workers that never sleep.
There's a second, sneakier cost. That floor competes with your ceiling. If minProcesses across thirty
queues already pins thirty workers and maxProcesses is 40, the auto balancer has only ten processes left to
throw at whichever queue is actually on fire. The floor you pay for idle queues is capacity stolen from the busy one.
Raising maxProcesses to compensate just raises the idle floor's headroom cost too.
Option B: balance => false — starvation by list order#
Flip it the other way. With balance => false, Horizon runs a single shared pool of workers, all listening to
every queue, and each worker checks the queues in the order you listed them. No per-queue floor, no idle minimum — workers
go wherever the work is. On paper this is the efficient option:
'supervisor-1' => [
'connection' => 'redis',
'queue' => ['high', 'default', 'low'],
'balance' => false,
'maxProcesses' => 10,
],
The catch is in the phrase "in the order you listed them." Without balancing, the queue list is a strict left-to-right
priority. A worker only looks at default when high is empty, and only looks at low
when both are empty. Under light load that's invisible and fine. Under sustained load it's a starvation machine:
A flood on a higher-priority queue starves every queue below it until the flood drains.
Push fifty thousand jobs onto high and your entire pool sits there draining it. default and
low don't get slow — they get nothing, for as long as high stays non-empty. The
lower queues aren't deprioritized; they're switched off. The exact isolation that balancing guaranteed is the thing you
just gave up to escape the idle-worker floor.
What is the difference between balance auto, simple and false?#
Horizon ships two balancing strategies and an off switch, and the gap between the two strategies is smaller than
most people assume. Both 'auto' and 'simple' give every queue its own pool of worker processes;
they differ only in whether that allocation is allowed to move.
'simple'splits the supervisor's processes evenly across the queues in the list and leaves them there. Ten processes across two queues means five each — whether or not one of them has been empty all day.'auto'starts from the same per-queue pools but continuously reallocates them to match each queue's workload — scaling a busy queue up towardmaxProcessesand a quiet one back down tominProcesses. (Whether "workload" means queue size or estimated time-to-clear is itself configurable, viaautoScalingStrategy.)falseis not a balancing strategy at all. There are no per-queue pools: one shared pool of workers listens to every queue and checks them in the order you listed them.
Laid out side by side, the trade-off stops being subtle. The last column is Skyline's addition, and it is the only one with no entry in the cost columns:
'simple' |
'auto' |
false |
false + weights |
|
|---|---|---|---|---|
| Worker pools | One per queue, fixed even split | One per queue, resized to load | One shared pool | One shared pool |
| Idle-worker floor | maxProcesses ÷ queues, always resident |
minProcesses per queue (defaults to 1) |
None | None |
| Can a queue starve? | No | No | Yes — strict list order | No — every queue keeps a share |
| Priority model | None (even split) | None (load-driven) | Strict left-to-right | Proportional (3 : 2 : 1) |
| Adds capacity to a spike | No | Yes — scales processes up | Shared pool absorbs it | Shared pool absorbs it |
The upstream reference for the two strategies lives in the Horizon balancing documentation. What it doesn't spell out is that choosing between them is really a choice about which cost you prefer to pay.
Why the trade-off gets worse with every queue you add#
The two options are mirror images, and neither is free:
- Balancing on gives you isolation (no queue starves) and charges you a per-queue process floor (idle workers, and a ceiling eaten by the floor).
- Balancing off gives you an efficient shared pool (no idle floor) and charges you starvation (strict priority order means a flood on one queue freezes the rest).
The reason this bites teams with many queues specifically is that both costs scale with queue count. More queues means a taller idle floor under Option A, and a longer priority list with more queues-below-the-flood to starve under Option B. The granularity that made thirty queues attractive is the same thing that makes both classic answers expensive.
Skyline's third option: weight the queues#
The hidden assumption behind the dilemma is that "balancing off" must mean strict priority — all of
high before any of default. Skyline breaks that assumption. It keeps the single efficient shared
pool of balance => false — no per-queue minimum, no idle floor — but replaces all-or-nothing list order with
a proportional one via a queueWeights map:
'supervisor-1' => [
'connection' => 'redis',
'queue' => ['high', 'default', 'low'],
'balance' => false,
'maxProcesses' => 10,
'queueWeights' => [
'high' => 3,
'default' => 2,
// 'low' omitted → default weight of 1
],
],
Instead of always checking high first, workers check the queues in proportion to their weights — roughly
3 : 2 : 1, so about 50% of pickups favour high, 33% default, and 17%
low. Important work still gets the lion's share of attention, but every queue keeps getting served. Queues
you don't mention default to a weight of 1, so adding the map never silently demotes anything.
Weighted queues turn priority from a switch into a dial. The busy queue gets more, not everything.
Now replay the flood. Fifty thousand jobs land on high. Under strict priority the pool would vanish into it
and low would freeze. Under weights, the pool still pours most of itself into high — it's
weighted highest, exactly as intended — but one in six pickups keeps reaching low, so it drains steadily
instead of stalling. The flood is absorbed without taking hostages.
Why weighted queues beat both balancing modes#
Line the three options up against what you actually wanted:
- No idle-worker floor. Weights live under
balance => false, so there's no per-queueminProcesses. Thirty queues do not mean thirty resident workers — they share one right-sized pool, the same as plain unbalanced mode. - No starvation. Every queue carries a weight of at least 1, so every queue is checked on a regular cadence. A flood can't reduce a sibling queue to zero throughput; it can only reduce its share.
- Intent made explicit. A 3 : 2 : 1 map says exactly how much each queue matters, which is far more honest than encoding priority as the accident of list position — and far cheaper than expressing it as dedicated worker pools.
- All your per-queue operations survive. You keep the thirty-queue granularity that started this — per-queue stats, pause, drain, inspect — without paying the balancing tax for it.
Balancing made you choose between wasting workers and starving queues. Weighting refuses the choice: one shared pool (Option B's efficiency) that never lets a queue rot (Option A's isolation), with a proportional dial in place of a binary priority order.
When should you still use balance => 'auto'?#
Weighted queues aren't a blanket replacement for balance => 'auto', and it's worth being precise about the
seam. Auto-balancing's real strength is elastic capacity — spinning up many workers for a queue that's spiking
and tearing them down afterward, scaling the total process count to demand. Weights don't add processes; they redistribute
attention across a fixed pool. If your bottleneck is raw throughput on one queue and you have CPU headroom to burn,
auto-scaling still has a place.
The point isn't that weighting wins everywhere. It's that the specific, common situation — many queues kept for operational granularity, most of them idle most of the time, with occasional floods you don't want to be contagious — is exactly where the classic balance on/off choice is worst, and exactly where weighted queues shine.
The core takeaway#
Running many queues is good operational hygiene: it's how you get per-queue visibility and per-queue control. Horizon's two balancing modes each tax that hygiene from opposite directions — balancing on charges you a floor of idle workers proportional to your queue count, balancing off charges you starvation proportional to your priority list. Skyline's weighted queues keep the efficient single pool of unbalanced mode and add a proportional policy on top, so important work is favoured without anything being left to rot. You get the isolation of balancing and the efficiency of no balancing, and you stop having to pick.
The weighted queues documentation covers the configuration in full. If you're still deciding which queue a job belongs on in the first place, that's practice #2 in 12 best practices for Laravel background jobs — and if your queues are backing up because jobs are colliding on locks rather than competing for workers, Laravel job uniqueness controls is the one to read next.
Frequently asked questions
What is the difference between Horizon's balance auto and simple?
Both give every queue its own pool of worker processes. simple splits the available processes evenly across the queues and leaves them there. auto continuously reallocates them based on each queue’s workload, scaling a busy queue up and a quiet one back down to minProcesses. Neither lets a queue starve, and both pay a per-queue process floor.
Should I turn Horizon balancing off?
Only if you accept strict priority. With balance => false every worker listens to every queue and checks them in the order you listed them, so a queue is only serviced when everything to its left is empty. It is the most process-efficient mode and the reason a flood on one queue can freeze the rest.
How do I stop one Laravel queue from starving another?
Either turn balancing on, which gives each queue a guaranteed minimum worker count at the cost of a permanent idle floor, or use Skyline’s queueWeights map, which keeps the single shared pool of balance => false but checks queues proportionally (3:2:1) instead of in strict order, so no queue ever drops to zero throughput.
Does minProcesses apply when balance is false?
No. minProcesses is a per-queue floor, and unbalanced mode has no per-queue pools — there is a single shared pool sized by maxProcesses. That is exactly why balance => false has no idle-worker cost.
Keep reading
16 min read
Rate-Limited APIs and Laravel Queues: One Request at a Time, Without Starving the Rest
How to run Laravel jobs against an API that rate-limits you and allows no parallel requests: middleware,...
11 min read
Laravel Background Jobs: 12 Best Practices for Production Queues
Twelve production-tested practices for Laravel queued jobs: small arguments, idempotency, sane retries,...