Laravel

Where the Queue Forms: Workers, Connections, Utilisation

Fixed pools, arrival rate, and why the ninety-ninth percentile breaks first.

Chapter 2 of 1312 min readOpen access

Where the Queue Forms is the model the rest of this book is a set of instances of. A slow application under load is almost never uniformly slow: it is a queue standing in front of one fixed resource, and naming that resource turns eleven chapters of tactics into an ordered search.

Key takeaways

  • A bottleneck is a queue in front of a fixed pool. PHP's manual describes pm.max_children as setting the limit on the number of simultaneous requests that will be served, which makes it a pool size in the literal sense.
  • Utilisation is arrival rate times service time divided by pool size. Halving service time and doubling arrival rate leave utilisation unchanged, which is why a query optimisation can vanish into growth.
  • As utilisation approaches one, waiting time grows far faster than service time, so the tail percentile degrades long before the mean moves and long before any CPU graph looks busy.
  • Google's SRE book, chapter 22, argues that queue length should be small relative to the pool, on the order of 50 per cent or less, so that excess requests are rejected early rather than accumulated.
  • Laravel 13 added transaction-mode PostgreSQL pooling through pooled and direct connection config, and db:monitor --max dispatches DatabaseBusy when open connections exceed a threshold you set.

Read this immediately after Chapter 1, which taught you to collect saturation, because this chapter is what saturation means. Chapter 7 applies the same model to queue workers and Chapter 8 applies it to Octane, and neither rebuilds it. Chapter 12 uses it to decide when a load test has found saturation rather than just found a wall.

Traffic doubles over a quarter. Nothing was deployed. The mean response time moves by a few per cent and nobody would notice it on a graph.

The ninety-ninth percentile has tripled. Support tickets are about checkout, and only checkout, and only sometimes. The infrastructure dashboard shows CPU below half on every web node, which is the fact that sends the team looking in the wrong place for a fortnight.

The CPU is idle because the workers are not computing. They are waiting.

A bottleneck is a queue in front of a fixed pool

Every component in a Laravel request path has a fixed number of things it can do at once. That number is the pool size, and it is almost always configured rather than discovered.

PHP-FPM's manual is unusually blunt about this. It describes pm.max_children as setting the limit on the number of simultaneous requests that will be served, and marks both pm and pm.max_children as mandatory. pm itself takes static, ondemand or dynamic, and under ondemand the manual documents pm.process_idle_timeout at a default of ten seconds.

The consequence is arithmetic rather than opinion. If twelve children are busy, the thirteenth request does not get served slowly. It waits, in a socket backlog, accruing latency that no PHP-level instrument will attribute to PHP.

That is the whole model. Everything else in this chapter is applying it.

Name every pool, and write its size next to it

Do this on one page, once, and keep it. Most teams cannot produce these five numbers on demand, and the exercise itself usually finds the bottleneck.

Under FPM, the request pool is pm.max_children. Under Octane, Laravel's documentation states that Octane starts one application request worker per CPU core by default, adjustable with --workers. Those are different defaults with the same meaning, and swapping runtimes swaps one pool size for another.

Behind that sits the database connection pool, bounded by the server's own connection limit rather than by anything in your application. Then the Redis connection pool, then the queue worker pool, which Chapter 7 treats in detail.

There is a sixth that people forget: any external API you call synchronously has a pool too, and it belongs to someone else. Its size is not yours to set.

Utilisation is the number that predicts the tail

Utilisation is arrival rate times service time, divided by pool size. The numbers below are placeholders for yours and nothing else; substitute your own before drawing any conclusion.

Take a pool of twenty workers and a service time of two hundred milliseconds. Each worker completes five requests per second, so the pool tops out at one hundred per second. At fifty arrivals per second utilisation is one half, and there is a free worker most of the time. At ninety arrivals per second it is nine tenths, and there frequently is not.

The unhelpful part is that both readings can produce an acceptable mean. Utilisation is a ratio, and the two ways to raise it are more arrivals or slower service. That symmetry is why a genuine query improvement can leave your latency graph untouched: you halved service time, growth doubled arrival rate, and utilisation sat still.

Write utilisation down per pool, as a fraction. It is the only number in this book that predicts what the next traffic increase will do to you.

Why the ninety-ninth percentile breaks first

Waiting time and service time are two different quantities, and only one of them is in your code. As utilisation approaches one, the waiting component grows far faster than the service component, because a request now usually arrives to find every worker occupied.

The mean is dominated by the majority of requests that found a free worker. The tail is made entirely of the minority that did not, and it is that minority which contains the checkouts, because slow requests hold workers longer and therefore make the queue worse for whoever arrives next.

There is a second reason the tail moves first, specific to this stack. Requests are not homogeneous. A route that opens a transaction and holds a row lock occupies its worker for a long time, and while it does, the pool is effectively smaller for everybody else. One slow route shrinks the pool for the fast ones.

That is why a CPU graph is a poor saturation signal for PHP. A worker blocked on a database read consumes almost no CPU and consumes the whole worker.

Queue length should be small, not generous

The instinct on discovering a queue is to make it bigger. Google's SRE book, chapter 22, argues the opposite, and its reasoning transfers directly to a PHP listen backlog.

That chapter recommends keeping queue length small relative to the pool, on the order of 50 per cent or less, so that excess requests are rejected early rather than accumulated. It gives the arithmetic for why: a queue ten times the thread count can multiply request handling time by roughly eleven, which is a number published in that book rather than one measured here.

The insight underneath is about honesty. A deep queue does not add capacity, it converts a rejection into a timeout, and a timeout is more expensive for everyone involved. The client waits, the worker is held, and the response is discarded.

So set the backlog deliberately. A fast failure is a design output.

Under sustained overload, first in first out serves nobody

Chapter 22 of the same book makes a point that feels wrong until you follow it. Under sustained overload, last in first out, or a controlled-delay algorithm, beats first in first out.

The reason is that the oldest request in the queue is the least likely to still be wanted. Its user has refreshed, or given up, or the browser has timed out. Serving it consumes a worker to produce a response nobody will read, and it does so ahead of a request that is still live.

First in first out feels fair and behaves badly, because under overload it guarantees that every request is served late rather than that some are served on time. Fairness is not the objective. Completed work is.

Laravel does not expose this choice at the FPM layer, so it belongs to your proxy or your own admission control. It does surface for queue jobs, which is Chapter 7's problem.

The pool behind the pool is the database connection

The pool that limits a Laravel application is very often not the one the application configures. Every FPM child that opens a connection consumes one of the database server's, and multiplying children across nodes is how teams exhaust it by accident.

Laravel 13's database documentation, fetched on 29 July 2026, gives you the instrument. db:monitor accepts --databases and --max, and dispatches a DatabaseBusy event when open connections exceed the threshold. That is a saturation alarm for a pool you do not own.

Laravel 13 also added support for transaction-mode PostgreSQL poolers, configured as a pooled connection alongside a direct one. The documentation records two details that matter operationally: emulated prepares are enabled automatically on the pooled connection, and migrations, schema dumps, db:wipe, db:show and db:table are routed to the direct connection instead.

Read that as a statement about pool sharing. A transaction pooler lets many application workers share fewer database connections, at the cost of features that need a session, which is exactly why the schema commands are routed around it.

PoolWhat sets its sizeWhat saturation looks likeWhat you read
FPM childrenpm.max_children, mandatoryLatency added before PHP starts, invisible to in-app timingFPM status page, listen backlog
Octane workers--workers, one per core by defaultSame, plus per-worker memory growthOctane worker count against cores
Database connectionsServer connection limit, shared across all nodesConnection errors, or waits with idle CPUdb:monitor --max, DatabaseBusy
Redis connectionsServer limit and per-worker client countCommand latency rising with no memory pressureConnected clients, command latency
Queue workersSupervisor or Horizon process countsBacklog age growing while depth looks flatWait time per queue, Chapter 7

A worked reading, composited and labelled

The details here are composited from ordinary production shapes rather than one system, and the shape is exact.

An application is slow at peak. The first reading pairs two graphs on the same time axis: per-route latency percentiles from Chapter 1, and busy FPM children as a fraction of pm.max_children. If the busy fraction reaches its ceiling and stays there while the tail climbs, the queue is at the web tier and no query change will move it.

The second reading is the same latency graph paired with open database connections against the server limit. If busy children sit below the ceiling while connections sit at theirs, the queue is one layer deeper, and adding children will make it worse rather than better.

Those two readings differ in kind, not in degree, and each licenses exactly one action. That is the point of the exercise: the graphs are chosen so that they cannot both be true.

The objection: so add more workers

This is the first thing every team tries, and it is right about a third of the time. It is worth knowing which third.

Adding workers helps when the pool immediately downstream has spare capacity, and hurts when it does not. Raise pm.max_children past what the database can accept and you have not added throughput, you have moved the queue somewhere with a worse failure mode: connection exhaustion instead of a socket backlog. The requests still wait. They now wait while holding a PHP worker and a partially initialised framework.

There is a memory ceiling too. Each child holds its own copy of the application, so the honest upper bound is available memory divided by peak per-worker usage, and PHP's manual notes that pm.max_requests defaults to zero, meaning endless request processing, and exists to work around memory leaks in third-party libraries. Setting it non-zero is a mitigation for a leak rather than a tuning win.

The rule I use: never raise a pool size without naming the pool downstream of it and stating its headroom. If you cannot name it, you are not tuning. You are moving the queue.

Chapter summary

A bottleneck is a queue in front of a fixed pool, and the pool sizes in a Laravel application are configured rather than emergent: PHP's manual defines pm.max_children as the limit on simultaneous requests served and marks it mandatory, Laravel's Octane documentation starts one worker per CPU core by default, and behind both sit the database connection limit, the Redis connection limit and the queue worker count. Utilisation is arrival rate times service time divided by pool size, which means it can be raised either by more traffic or by slower service, and a real service-time improvement absorbed by growth leaves the latency graph unchanged. As utilisation approaches one the waiting component grows far faster than the service component, so the tail percentile degrades while the mean holds and while the CPU graph reads idle, because a worker blocked on a database read costs no CPU and costs a whole worker. Google's SRE book, chapter 22, argues for keeping queue length small relative to the pool, around 50 per cent or less, so excess load is rejected early, and it argues that under sustained overload last in first out beats first in first out because the oldest request is the least likely to still be wanted. Laravel 13 gives you a saturation alarm for a pool you do not own, through db:monitor --max and the DatabaseBusy event, and adds transaction-mode PostgreSQL pooling with pooled and direct connections, routing schema commands to the direct one. And no pool size should be raised without naming the pool downstream and stating its headroom.

With the model in place, the search begins where the queue most often forms in a Laravel application, which is not the web tier at all. Chapter 3 is The Query Layer, and it is about what Eloquent actually emits when nobody is watching.

Sources

  1. PHP Manual: FPM configurationThe PHP Group · 2026-07-29 · Official documentation · verified
  2. Addressing Cascading FailuresGoogle, Site Reliability Engineering, chapter 22 · 2016 · Vendor engineering · verified
  3. Database: Getting StartedLaravel · 2026-07-29 · Official documentation · verified
  4. Laravel OctaneLaravel · 2026-07-29 · Official documentation · verified