justinholtweb/craft-heat

Find out how hot your Craft site can run before it melts — measures what a page really costs, then predicts what happens at 500, 5,000 and 50,000 visitors.

Maintainers

Package info

github.com/justinholtweb/craft-heat

Documentation

Type:craft-plugin

pkg:composer/justinholtweb/craft-heat

Transparency log

Statistics

Installs: 1

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

5.0.0 2026-08-18 00:10 UTC

This package is auto-updated.

Last update: 2026-08-18 12:37:25 UTC


README

Find out how hot your Craft site can run before it melts.

Heat measures what a request genuinely costs your site on your machine, then works out what happens when there are ten thousand of them — which resource runs out first, at how many visitors, and what to do about it.

Ceiling
────────────────────
  41.2 requests per second, limited by the PHP worker pool.
  1. PHP workers                  41.2 rps   8 concurrent PHP requests
  2. CPU                          88.1 rps   10 cores available to the web tier
  3. Database                    123.9 rps   about 10 queries running at once
  4. Database connections        514.9 rps   100 of 100 connections usable

Launch day (cold cache)
───────────────────────────────────────────
  visitors     req/s    util        p50        p95  in flight  verdict
       100       9.8     24%      167ms      314ms       1.9  Comfortable
       250      24.5     59%      172ms      338ms       5.0  Comfortable
       294      28.8     70%      182ms      379ms       6.3  Comfortable
       355      34.5     84%      223ms      559ms       9.6  Queueing
       416      39.2     95%      459ms      1.58s      24.2  Falling over
     1,000      41.1    100%      9.95s     42.68s     588.8  Falling over

  Comfortable up to 294 simultaneous visitors; falling over by 416.

The problem

There are two kinds of answer to "can the site handle the launch", and both are useless.

The first is a load test. It hits the site until something breaks and reports the number it broke at. It is real, and it tells you almost nothing you can act on: you learn that 270 concurrent requests was too many, not which resource ran out, not what to change, and not what the number becomes after you change it. Worse, the honest way to run one is from another machine entirely, which is exactly the thing nobody has set up at the moment they need the answer.

The second is a spreadsheet. Cores, workers, an assumed page weight, some multiplication. It is actionable and it is fiction, because it has never met your templates, your query count, or the fact that your homepage does forty-one things before it renders.

Heat does the half of each that works. It measures the real cost of a request — wall time, real CPU time, database time and query count, memory, and time blocked on somebody else's API — and it reads the real limits of the machine: the FPM pool, the cgroup CPU quota, the connection limit, the database's actual query parallelism. Then it does the arithmetic that turns those into a wall, and tells you which wall, and what is behind it.

What it actually computes

The ceiling is arithmetic. If a request occupies one of m things for D seconds, the system cannot complete more than m/D requests per second. That is operational law: it holds for any arrival pattern, any scheduler, any shape of traffic, and it needs no assumptions at all. Heat computes it for every finite resource a request touches and reports the smallest. This is the number to bet on.

The latency is a model. How bad it gets before the ceiling depends on how requests bunch up, which needs a queueing assumption — M/M/c, solved through Erlang-C. It is an approximation, and it is the right one, because a resource with c servers and roughly independent arrivals is precisely what an FPM pool is. Heat says which of its numbers are which, rather than presenting both in the same voice.

The site is a closed population. N visitors, each of whom pauses, then clicks. This matters more than any coefficient. An open model — "assume 900 requests per second arrive" — has no answer past saturation at all: the queue goes to infinity and the prediction becomes the word "infinity". Real visitors wait when a site is slow, so they click less often, so the arrival rate falls. That feedback is what makes the numbers past the ceiling both finite and true — throughput flattens, latency climbs linearly, and the site grinds rather than explodes.

Visitors are not requests. Ten thousand people reading a page for ten seconds each generate about a thousand requests per second, not ten thousand. Nearly every capacity argument that goes wrong goes wrong exactly there, so think time is a first-class input rather than a hidden constant.

The measurement pass

It is not a load test, and that is the design rather than a shortcut. The model needs service demand — how long one request occupies a worker with nothing else in the way — and then does the extrapolation itself, rigorously. Measuring under load gives a number already contaminated by queueing, which the model would then queue a second time; and a load generator running on the machine it is testing competes for the very workers it is trying to count.

So requests go out one at a time, a few dozen in all, and the site never notices. A pass is safe to run against production in a way a load test never is — which matters, because production is the only machine whose answer anybody wanted.

What it measures, per scenario:

how
Wall time instrumentation inside the request, not the client's stopwatch
Real CPU time getrusage(), so working and waiting are actually distinguished
Database time and query count Yii's profiler, in a separate pass — see below
Peak memory what a worker has to be sized for
Whether PHP ran at all a 200 with no instrumentation header means a proxy or CDN answered

Two details carry more weight than the rest:

The profiler is not free, so it runs in its own pass. Yii renders the raw SQL of every statement to use as a profiling label — real work, charged to the request being measured, and not a rounding error on a page running two hundred queries. So wall, CPU and memory are taken with profiling off, query count and database time with it on. Database timings are taken around execution itself, so the two passes compose without reconciliation. A tool whose measurement inflates the thing it measures is the exact failure this exists to avoid.

CPU is measured, not inferred. Wall time minus database time would charge a request blocked on a payment gateway to the CPU, and the model would then confidently recommend more cores that would not help by a single request per second. What is left after real CPU and real database time is reported as what it is: blocked on something else.

The logged-in path is measured by creating a throwaway account, signing it in through the ordinary login action, and deleting it afterwards — no privileged shortcut, no session-forging endpoint. The editor and queue paths are measured in-process, doing the real work: duplicating an entry that actually exists on the site so the save carries real content, and running a real search-index job. Both are hard-deleted immediately, and orphans from an interrupted run are cleared at the start of the next one.

It also prices a Craft request that does nothing at all, which is a number most people have never seen and often the most surprising line in the report.

The resources it models

Resource Servers Held for Runs out when
PHP workers pm.max_children the whole request dynamic traffic exceeds the pool
CPU cores, or the cgroup quota the CPU portion the pool is bigger than the cores can feed
Database real query parallelism query time queries queue behind each other
Database connections max_connections the whole request the pool outgrows the limit

The last two are deliberately separate numbers. max_connections is how many clients may be connected; it is usually in the hundreds and it is not a throughput limit. Four hundred connections on four cores does not run four hundred queries — it runs four and queues three hundred and ninety-six. Confusing the two is why "we raised max_connections and it got slower" is a thing people say.

Memory is not a queue, so it is not modelled as one. It caps the worker count instead: a pool configured beyond what RAM supports does not queue, it forks until the OOM killer picks something — frequently the database. Heat models the pool the machine can actually run and says so.

What it reads off the machine

  • The FPM pool, from the pool configuration — across the layouts Debian, RHEL, Alpine, Homebrew and Bitnami each settled on — preferring the pool belonging to the PHP that is running, because a box with several versions installed will otherwise hand you the worker count of an interpreter that has not executed anything in years.
  • cgroup v2 and v1 limits before the host's own figures. A container reporting 32 cores through /proc/cpuinfo may have a quota of two, and a worker pool sized off the former gets throttled into the ground with every dashboard showing idle CPU.
  • The database's connection limit, buffer pool and current usage, on MySQL/MariaDB and Postgres.
  • Craft's own configuration, for the settings that change capacity before any hardware does: devMode left on, template caching disabled, a per-machine cache that will not survive a second web server, transforms generated in-request, and runQueueAutomatically — with which the queue is not a background process at all but HTTP requests taking workers from the same pool as your visitors.

Every value carries how it was obtained: read, inferred, or defaulted. Nothing inferred is ever presented as measured, because a confidently stated worker count that was really a guess is the single most common way a capacity prediction goes wrong.

The advice

Naming a bottleneck is the easy half. "You are worker-bound" is true of nearly every Craft site under load and tells nobody what to change on Monday. So each wall comes with what to do about it, quoting the numbers measured on your machine, and ranked walls rather than just the binding one — because the second entry is what makes the first actionable:

There is RAM for 369 workers and only 8 are configured. Raising pm.max_children to 369 moves this ceiling to about 1,900 requests per second — but check the next wall before you do: CPU gives out at 88.1 requests per second, so the real gain stops there.

It also says when not to act. Heat will not recommend a larger pool on a box without the RAM to hold one, and it distinguishes the fixes that multiply capacity from the ones that merely add to it:

Only 25% of the modelled traffic is served without touching PHP. Requests answered by a reverse proxy, a static cache or a CDN edge never take a worker, so they do not count against this ceiling at all — pushing the hit rate to 80% would put this ceiling at roughly 154 requests per second without changing a line of the site. This is the only fix on the list that multiplies capacity; everything else adds to it.

Traffic mixes

Four presets, over five kinds of request — cached visitor, rendered visitor, logged-in visitor, editor saving an entry, and queue job.

Mix For
Normal browsing a settled site with a working cache and a trickle of editing
Launch day the case worth planning for: the campaign lands and the cache is cold
Membership site almost nothing cacheable, because almost everyone is signed in
Editorial rush a team publishing hard, and a queue full of the work those saves created

Sites do not fall over at their busiest hour. They fall over at their busiest hour with an empty cache, which is why the launch mix exists and why a cached and an uncached page are separate costs rather than one averaged one.

Console

Run it over SSH on the production host and the answer is about production, which is otherwise the largest single source of wrong capacity numbers.

craft heat/measure                        # measure what a request costs here
craft heat/measure --save                 # …and keep the resulting projection
craft heat/measure/forget                 # go back to estimates
craft heat/capacity                       # project the default mix
craft heat/capacity --mix=launch          # cold-cache launch day
craft heat/capacity --workers=64 --cores=16
craft heat/capacity --users=500,1000,2500 --save
craft heat/capacity/machine               # what was read, and what was guessed
craft heat/capacity/mixes                 # the available traffic mixes

Control panel

  • Overview — the ceiling, the comfortable and breaking visitor counts, the ranked walls, the load ladder and the advice, with a button to measure. Until a pass has run, the costs are documented estimates and the page says so in as many words.
  • Runs — a projection kept together with the machine and the assumptions it was taken against, and replayed from those rather than from today's. Capacity questions are never asked once; the interesting form is what changed.
  • Settings — machine overrides, so a laptop can project production, and the limits a measurement pass is allowed to push to.

Editions

Lite Pro
Capacity model, ranked walls, remediation
Machine probes and overrides
Measurement pass
Visitor and logged-in visitor load
Preset traffic mixes
Console projections
Keep the latest projection
Editor and queue load
Run history, to compare two projections

Lite models what visitors do to a site; Pro also models what the people running it do — concurrent editors saving entries, and the queue work those saves create. Comparison is Pro for the same reason: a single capacity number is a fact about today, and a pair of them is a fact about a change.

Planned for Pro: custom scenarios and workload mixes, scheduled projections, and regression alerts.

Requirements

Craft CMS 5.3+ and PHP 8.2+. No runtime dependencies.

Installation

composer require justinholtweb/craft-heat
php craft plugin/install heat