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.
Package info
github.com/justinholtweb/craft-heat
Type:craft-plugin
pkg:composer/justinholtweb/craft-heat
Requires
- php: ^8.2
- ext-json: *
- craftcms/cms: ^5.3.0
Requires (Dev)
- craftcms/ecs: dev-main
- craftcms/phpstan: dev-main
- pestphp/pest: ^3.5
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/cpuinfomay 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:
devModeleft on, template caching disabled, a per-machine cache that will not survive a second web server, transforms generated in-request, andrunQueueAutomatically— 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_childrento 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