Monitoring Laravel Queues with Horizon: The Metrics That Reveal Bottlenecks
Horizon gives you three numbers that settle everything: throughput, job runtime, and wait time. If wait time climbs while runtime stays flat you need more workers, and if runtime itself grows the problem is your code, not the worker count.
Laravel Horizon is a monitoring dashboard for Redis queues that hands you three numbers to base every decision on: throughput (jobs completed per minute), runtime (how long a single job takes to run), and wait time (how long a job sits in the queue before a worker picks it up). Reading these three correctly tells you exactly where the bottleneck is. If wait time climbs while runtime stays flat, jobs are arriving faster than your workers can swallow them, and the fix is more workers or a higher process ceiling in config. If runtime itself grows, every job got heavier, and the problem lives in your code or a slow database query inside the job, so adding workers here just burns server resources for nothing. To enable Horizon you install it with composer require laravel/horizon, run php artisan horizon:install, then start it with php artisan horizon, which reads config/horizon.php and manages workers for you instead of hand-running queue:work. The dashboard opens at /horizon and shows queues, failed jobs, and live metrics moment by moment. This article explains how to read each number, when to reach for the balance=auto strategy versus simple, how many workers a mid-tier server can hold in real numbers, and shows one supervisor versus several priority queues.
What Horizon Actually Measures
Throughput is the count of jobs finished per unit of time, and it is your first signal of system health. If jobs arrive at 200 per minute and your throughput is only 120, you accumulate 80 jobs every minute and the queue grows until wait time explodes. Runtime measures the span from the moment a worker grabs a job to the moment it finishes, a direct reflection of code weight: sending an email might take 300 milliseconds while generating a large PDF report might take 8 seconds. Wait time is the gap between a job entering the queue and being picked up, and it is the number your user actually feels while waiting for a confirmation message or an invoice. Horizon shows all three per queue, so you can see plainly that the emails queue is healthy while the reports queue is choked, and act on data instead of guesswork.
Reading the Numbers Together
The practical rule is simple: watch wait time and runtime side by side. Rising wait with flat runtime means you lack capacity, so add workers. Rising runtime itself means a job got slower, so open the code and hunt for an N+1 query or an external API call with no timeout. Both flat while throughput drops usually means your workers are restarting often or dying unsupervised, so check the supervisor setup on the server. Set an alert on a specific wait threshold, so that if the invoices queue crosses two minutes of wait, you hear about it before the customer complains.
balance=auto vs simple vs false
Horizon distributes workers across queues with three strategies. simple spreads workers evenly across queues without regard to load, so with three queues and ten workers each queue gets a roughly fixed share no matter how busy it is. auto is the smart choice: it watches wait time per queue and shifts workers toward the busiest one, so when the reports queue suddenly fills, Horizon pulls workers from a quiet queue and throws them at reports until it calms down. false disables balancing and pins each queue to the worker count you set, which suits cases where you want a hard guarantee that a sensitive queue is never starved. For the vast majority of applications, auto is correct because it adapts to fluctuating load with no manual intervention.
| Strategy | How it assigns workers | When to pick it |
|---|---|---|
| simple | Fixed even split across queues | Stable load, similar across queues |
| auto | Shifts workers dynamically toward load | Default for most applications |
| false | Fixed manual count per queue | Hard guarantee for a sensitive queue |
With auto you also set minProcesses and maxProcesses, guaranteeing a floor of workers always ready and a ceiling it never crosses, so Horizon cannot eat all your server RAM under a sudden spike.
How Many Workers Your Server Holds, by the Numbers
Each Horizon worker is an independent PHP process consuming a fixed amount of RAM, usually between 40 and 80 megabytes for a typical Laravel application depending on code size and loaded packages. On a Growth cloud server with 2 vCPU and 4GB RAM, after subtracting the operating system, PHP-FPM, and Redis, you have around 2.5GB left for worker processes, comfortable room for roughly 25 to 35 concurrent workers if your jobs are light (email, notifications). But the count is not the whole story: just two workers running heavy PDF-generating jobs can saturate those 2 vCPU completely, because the constraint here is the processor, not memory. So capacity is measured by real testing: raise workers gradually and watch vCPU usage in the server dashboard, and if it pins at 80% with no improvement in throughput, you have hit the ceiling and need to upgrade the plan or add vCPU rather than cram in more workers. One added vCPU on CLOUD costs 500 SAR per year, an extra 2GB RAM costs 600 SAR per year, and the upgrade is instant with no service interruption.
One Supervisor vs Several Priority Queues
The common mistake is piling every job into a single default queue, where an urgent invoice sits in the same line as a deferred marketing email. The cleaner approach defines several supervisors in config/horizon.php, each serving a queue of a different priority. For example a supervisor for the high queue gets dedicated workers and first priority for invoices, payments, and time-sensitive alerts, another supervisor for the default queue handles ordinary work, and a third for the low queue processes heavy reports and nightly cleanup at leisure. When dispatching a job you write queue('high') to guarantee it is picked first. With the auto strategy, Horizon moves workers between these queues by load while respecting the priority order you set. This separation protects the customer experience: an order confirmation reaches them immediately even if hundreds of scheduled reports wait in a low-priority queue.
Do Not Forget to Supervise the Process Itself
Horizon manages the workers, but who manages Horizon? On the server you need a process supervisor such as Supervisor or a systemd service that keeps the horizon command alive and restarts it if it dies or after each deploy. Without this supervision, the first fatal error stops Horizon entirely and every queue silently halts. After each new deploy run php artisan horizon:terminate, which gracefully ends current workers once their in-flight jobs finish, then the supervisor restarts it on the new code, so you never lose a half-executed job.
The Practical Takeaway
Start by installing Horizon, open /horizon, and watch only three numbers in the first week: throughput, runtime, and wait time. If wait alone rises, add workers or upgrade the plan. If runtime rises, open the code and fix the slow query. Split your jobs into high, default, and low queues by time sensitivity, and use balance=auto with clear minProcesses and maxProcesses. Install a process supervisor that keeps Horizon alive, and run horizon:terminate after every deploy. If vCPU hits its ceiling with no gain in throughput, the answer is an upgrade, not more crammed workers, and your upgrade on a CLOUD cloud server is instant with zero downtime.


