Horizon Queue Balancing: Idle Workers vs. Starved Queues
· 9 min read · Boring Observability
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.
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.
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: balancing on — 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: balancing off — 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.
The dilemma, stated plainly
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 this is the best of both worlds
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 you'd still reach for balancing
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.