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_childrenas 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
pooledanddirectconnection config, anddb:monitor --maxdispatchesDatabaseBusywhen 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.
| Pool | What sets its size | What saturation looks like | What you read |
|---|---|---|---|
| FPM children | pm.max_children, mandatory | Latency added before PHP starts, invisible to in-app timing | FPM status page, listen backlog |
| Octane workers | --workers, one per core by default | Same, plus per-worker memory growth | Octane worker count against cores |
| Database connections | Server connection limit, shared across all nodes | Connection errors, or waits with idle CPU | db:monitor --max, DatabaseBusy |
| Redis connections | Server limit and per-worker client count | Command latency rising with no memory pressure | Connected clients, command latency |
| Queue workers | Supervisor or Horizon process counts | Backlog age growing while depth looks flat | Wait 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
- PHP Manual: FPM configurationThe PHP Group · 2026-07-29 · Official documentation · verified
- Addressing Cascading FailuresGoogle, Site Reliability Engineering, chapter 22 · 2016 · Vendor engineering · verified
- Database: Getting StartedLaravel · 2026-07-29 · Official documentation · verified
- Laravel OctaneLaravel · 2026-07-29 · Official documentation · verified