The four walls

Heat reports the walls ranked, not just the binding one — because the second entry is what makes the first actionable. A database that gives out three percent behind the worker pool is a reason not to spend the weekend resizing the pool.

The PHP worker pool

Ceiling: workers ÷ time a request holds one.

A worker is held for the whole request, including every millisecond spent waiting on the database or an outbound API. That is what makes this the most common wall on a Craft site, and the one where the fix people reach for first is most often wrong.

What actually raises it

  1. Serve requests ahead of PHP. A reverse proxy, static cache or CDN edge answers without taking a worker at all, so those requests do not count against this ceiling. This is the only lever that multiplies capacity; everything else adds to it.
  2. Find the outbound call. If a large share of worker time is “blocked on something else”, a third party is holding your concurrency budget while using none of your CPU — which is why the box can look idle at the moment the site stops responding. Move it to a queue job, cache the response, or at minimum give it a timeout short enough that a slow vendor cannot hold your whole pool.
  3. Cut the time a worker is held. Halving it doubles the ceiling, and the CPU/database/blocked split tells you where to look.
  4. Raise pm.max_children — if, and only if, the RAM is there.

When Heat refuses to recommend a bigger pool

A pool that is too big fails worse than one that is too small. Under load it forks past what the box has, and the OOM killer picks a victim — frequently the database, which turns a slow site into a broken one. A pool that is too small at least degrades in a way you can read in a log.

So Heat models the pool RAM can actually support, says when the configured number describes a machine that cannot exist, and tells you to lower it rather than raise it.

CPU

Ceiling: cores ÷ measured CPU per request.

When this binds, the worker pool is configured larger than the CPU can feed, and raising it further only makes every request slower at the same total throughput.

  • OPcache on, and big enough to hold the whole codebase. Craft plus dependencies is tens of thousands of files; recompiling any of them per request shows up here and nowhere else, and it is the largest single line item on a misconfigured box.
  • Move the database off the machine if it shares one. It will not make queries faster, but it stops them competing with template rendering for the same cores.
  • Cache the expensive fragments — a {% cache %} around a nav or a listing removes the template work and the queries behind it.
  • Keep transforms out of the request.
  • Buy cores. This is the one wall you can genuinely buy your way out of — the ceiling rises proportionally.

Database parallelism

Ceiling: concurrent queries the server can genuinely run ÷ database time per request.

The limit is parallelism, not connections. Raising max_connections adds not one unit of the capacity that is actually short here.

  • Attack the query count first if it is high. In Craft that is almost always eager-loading: an element query in a template that then reads a relation, an asset or a Matrix field per result turns one query into one per row. .with([...]) collapses them back.
  • Size the InnoDB buffer pool to hold the working set. A query served from memory is CPU; the same query from disk is an order of magnitude of wait, charged straight to this ceiling.
  • Read the slow query log under load, not at rest. What matters is the merely-mediocre query that runs on every request, not the nightly report that takes four seconds.
  • Cache at the layer above. Removing a request’s database time entirely beats making its queries faster.

Database connections

Ceiling: usable connections ÷ time a request holds one.

A Craft request opens its connection early and holds it until the response is sent — for the whole request, not just while a query runs. Charging it query time instead would make this limit look dozens of times further away than it is.

When this binds, PHP is configured for more concurrent requests than the database will accept, and the surplus get “Too many connections” — a 500, not a slow page. Raise max_connections past the worker count (accounting for the per-thread buffers that costs on the database host), or put a pooler in front: ProxySQL for MySQL, PgBouncer for Postgres.

Either way, check what the query parallelism is afterwards. Connections are a permission to queue, not a promise of throughput.

Memory

Not a wall in the same sense, because it is not a queue — it caps the worker count instead, and Heat models the smaller of the configured pool and what RAM supports.

It also warns when the hungriest request in the mix needs far more than the average: the pool is sized on the average, so enough expensive requests arriving together can still exhaust RAM at loads the projection calls comfortable.