Craft CMS 5 plugin

How hot can your site run before it melts?

Heat measures what a request actually costs your site — CPU, database, memory, and the time it spends blocked on somebody else’s server — then works out what happens when there are ten thousand of them.

Which resource runs out first. At how many visitors. And what to change — including when the obvious fix is the wrong one.

Free Lite edition · Craft CMS 5.3+ · PHP 8.2+ · no runtime dependencies

Heat

“Can it handle the launch?”

There are two usual answers, and neither one is any use.

The 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 once you change it.

Worse, the honest way to run one is from another machine entirely, which is the thing nobody has set up at the moment they need the answer.

The 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.

Every number in it is a number somebody guessed, presented in the same voice as the ones that were measured.

Heat does the half of each that works. It measures what a request costs on the machine you actually run, then does the arithmetic that turns that into a wall — and tells you which wall, and what is behind it.

Step one

Measure, don’t assume

A few dozen requests, one at a time, from inside your own site. It is not a load test — and that is the design, not a shortcut.

$ craft heat/measure

  · Pricing a bare Craft request
  · Measuring pages Craft has to render
  · Measuring pages as a visitor gets them
  · Measuring the same pages signed in
  · Timing an entry save
  · Timing a queue job

Measured
  scenario                     wall       cpu  database   blocked  queries  memory
  Cached visitor page          9.4ms        —         —         —        —       —
  Rendered visitor page       218ms      74ms      91ms     6.2ms       84    47MB
  Logged-in visitor           344ms     119ms     162ms      11ms      131    56MB
  Editor saving an entry      627ms     198ms     318ms     8.4ms      240    94MB
  Queue job                   1.42s     402ms     975ms     6.1ms      196    81MB

  A bare Craft request costs 24ms before your templates do anything.
  71 requests in 19.4s.

Real CPU, not leftovers

CPU comes from getrusage(), so working and waiting are actually told apart. Wall time minus database time would charge a request blocked on a payment gateway to the CPU — and the model would then confidently recommend cores that would not help by a single request per second.

It doesn’t inflate what it measures

Yii’s query profiler renders the raw SQL of every statement to label it — real work, charged to the request being measured. So timing and query counts come from separate passes. A tool whose measurement changes the answer is the exact failure this exists to avoid.

A 200 with no metrics is the good news

If a page comes back without Heat’s instrumentation header, PHP never ran: a proxy, a static cache or a CDN edge answered and your origin was never involved. That request costs no worker, no connection and no query — and Heat models it that way.

Step two

Then find the wall

Not a percentage nobody acts on. The load at which requests start queueing, and the load at which visitors start getting 502s.

Ceiling

130

requests per second, limited by the PHP worker pool

Comfortable up to

920

simultaneous visitors, nobody queueing

Falls over at

1,264

timeouts, 502s, or waits nobody sits through

Based on

Measured

normal browsing · pauses 10s between pages

Visitors Requests/s Load Typical Slowest 5% In flight
1009.9 83ms156ms1.0 Comfortable
50049.5 83ms157ms4.8 Comfortable
92091.0 87ms175ms9.3 Comfortable
1,00098.9 89ms184ms10.6 Queueing
1,092108.4 95ms210ms12.1 Queueing
1,264123.7 319ms1.02s39.0 Falling over
2,000130.0 3.75s16.03s700 Falling over
5,000130.1 19.72s85.14s3,699 Falling over

Past the ceiling, throughput stops rising and only the wait grows. Around 1,264 visitors the queue on the listen socket passes the backlog, and the site stops returning slow pages and starts returning 502s.

Visitors, not requests

Ten thousand people reading a page for ten seconds each generate about a thousand requests a second, not ten thousand. Nearly every capacity argument that goes wrong goes wrong exactly there, so think time is an input you can see and argue with — not a constant buried in the maths.

Sites grind, they don’t explode

Heat models a closed population: when the site gets slow, people wait, so they click less often. That feedback is what makes the numbers past the ceiling finite — and what makes them match what actually happens. An open model’s answer past saturation is the word “infinity”.

What runs out, and in what order

The ceiling is arithmetic, not a model. If a request occupies one of m things for D seconds, nothing completes more than m/D requests a second — whatever the traffic looks like.

Resource Available Per request Ceiling Runs out when
1. PHP workers 12 92ms 130 rps dynamic traffic exceeds the pool
2. CPU 8 cores 53ms 151 rps the pool is bigger than the cores can feed
3. Database ~10 at once 39ms 255 rps queries queue behind each other
4. Database connections 151 92ms 1,638 rps the pool outgrows the limit

Connections are not throughput

max_connections is how many clients may be connected. It is usually in the hundreds, it is what everybody quotes, 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.

Which is why “we raised max_connections and it got slower” is a thing people say. Heat keeps the two as separate numbers, and says which one is short.

Memory isn’t a queue

So it isn’t 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, says so, and refuses to recommend making it bigger.

Step three

And what to actually do

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.

It quotes your own numbers

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

It says when not to act

Do not raise the pool. It is already configured for 200 workers, and at 256 MB of peak memory each, the 4 GB of RAM available supports 16. Under real load the pool will fork past what the box has and the OOM killer will pick something to kill — frequently the database, which turns a slow site into a broken one.

It tells multiplying apart from adding

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 — 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.

It finds the cost nobody looks for

Roughly 93% of every request’s worker time (2.80s of 3.00s) is spent blocked on an outbound HTTP call, not doing any work of your own. That time counts fully against this ceiling while using no CPU at all — which is why the server can look idle at the exact moment the site stops responding.

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.

It reads the machine you actually run

And it never presents something it inferred as something it measured.

The FPM pool

pm.max_children read from the pool configuration, across the layouts Debian, RHEL, Alpine, Homebrew and Bitnami each settled on — and Apache’s MaxRequestWorkers. It prefers 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 limits first

A container reporting 32 cores through /proc/cpuinfo may have a quota of two. Size a worker pool off the first number and the container gets throttled into the ground with every dashboard showing idle CPU. Heat checks the cgroup — v2 and v1 — before it believes the host.

Craft’s own settings

The configuration that changes capacity before any hardware does: devMode left on, template caching off, a per-machine cache that will not survive a second web server, transforms generated in-request — and the one nobody expects, below.

The queue is traffic

With Craft’s default runQueueAutomatically, the queue is not a background process at all — it is HTTP requests to your own site, taking workers from the same pool as the visitors. A resave of fifty thousand entries competes directly with traffic, and the site becomes unreachable on a box whose CPU graph looks bored.

Read, inferred, or guessed

Every value carries how it was obtained. A confidently stated worker count that was really a guess is the single most common way a capacity prediction goes wrong — so Heat says which numbers it read, and asks to be told the ones it could not.

Model production from a laptop

Override any of it — workers, cores, memory, connection limit, query parallelism — and a stated number is treated as fact rather than inference, because somebody who knows their own server configuration is a better source than any probe.

Getting Started

Install it, measure once, and read the answer.

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

Then, over SSH on the box you actually care about

# a few dozen sequential requests — safe on production
php craft heat/measure

# what this machine can take, and what gives out first
php craft heat/capacity

Planning for something specific

# the campaign lands and the cache is cold
php craft heat/capacity --mix=launch

# what would a bigger box actually buy?
php craft heat/capacity --workers=64 --cores=16

# what Heat could read here, and what it had to guess
php craft heat/capacity/machine

Or do all of it in the control panel

Heat adds an Overview screen with the ceiling, the ladder and the advice, a Measure button that runs the pass in the queue, and Runs — each one kept together with the machine and the assumptions it was taken against, and replayed from those rather than from today’s.

Pricing

Lite is free and genuinely useful — it is the half that lets you decide whether to trust the paid half. Pro is a one-off licence with an optional renewal for updates.

Lite

Free

  • The full capacity model and ranked walls
  • The remediation advice, in full
  • Machine probes and overrides
  • Measurement pass — visitor and logged-in load
  • All four preset traffic mixes
  • Every console command
  • The latest projection kept
Install Lite
The whole load

Pro

$129

one-off · $99/year for updates

  • Everything in Lite
  • Editor load — concurrent entry saves, measured for real
  • Queue load, and what it takes from your visitors
  • Run history, kept with the machine it was taken on
  • Compare two projections — before a release and after
  • Custom scenarios and workload mixes (coming)
  • Scheduled projections and regression alerts (coming)
Get Pro — $129

If a Pro licence lapses, Heat downgrades rather than breaks: your stored runs and measured costs are left untouched and the plugin keeps working with the Lite feature set, so renewing restores exactly what was there. And Lite says plainly that its ceiling excludes editor and queue load, rather than quietly reporting a higher number.

Questions

No, and that is the design rather than a reassurance. The model needs to know what one request costs with nothing else in the way, and then does the extrapolation itself — so a pass sends a few dozen requests one at a time, and the site never notices. Measuring under load would give 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. Runs on production are switched off until you turn them on anyway.
Because the ceiling is arithmetic, not a guess. If a request occupies one of m things for D seconds, the system cannot complete more than m/D requests a second — that holds for any arrival pattern, any scheduler, any traffic shape. Measuring gives D accurately; reading the machine gives m. What happens before the ceiling needs a queueing assumption, and Heat says which of its numbers are which rather than presenting both in the same voice.
A throwaway user account, signed in through the ordinary login action and hard-deleted afterwards — there is no privileged endpoint that logs a client in as somebody else, because that is the kind of convenience that turns into an advisory. On Pro, the editor probe duplicates one existing entry so the save carries real content, then hard-deletes the copy immediately; it is created disabled and kept out of any structure. Anything left behind by an interrupted run is cleared at the start of the next one.
Yes, and it is one of the main reasons the machine numbers are overridable. Set the worker count, cores, memory, connection limit and query parallelism you know production has, and Heat treats a stated number as fact rather than inference — somebody who knows their own server configuration is a better source than any probe. The costs still come from wherever you measured them, and the report says so when those two are different environments.
Because ten thousand simultaneous visitors is not ten thousand simultaneous requests. Someone reading a page for ten seconds is making 0.1 requests a second, so ten thousand of them offer roughly a thousand — a factor of a hundred in the size of server you buy. Nearly every capacity argument that goes wrong goes wrong exactly there, so Heat models people, asks how long they pause, and derives the request rate rather than quietly assuming the terrifying answer.
Heat will tell you, specifically. If there is RAM for it and the next wall is far enough away, it says so and quotes the new ceiling. If the pool is already larger than memory supports it refuses, because a pool that is too big fails worse than one that is too small — it forks until the OOM killer picks something, frequently the database. And if the next wall is three percent behind the first, it says that too, so you do not spend a weekend on it.
It measures it. A page that comes back without Heat’s instrumentation header was answered by a reverse proxy, a static cache or a CDN edge — PHP never ran, so that request costs no worker, no connection and no query, and the model treats it that way. It also reports the ratio between what a visitor gets and what the page costs to render, which is usually the most valuable number in the report. Blitz, Varnish, Cloudflare and friends all show up correctly without configuration.
No, and it does not compete with them either. A profiler tells you which function is slow in one request; Heat tells you how many of those requests the machine can run at once and which finite resource stops it. It uses getrusage() for CPU and Yii’s own query profiler for the database, both already present, and adds no runtime dependencies at all.
The whole capacity model, the ranked walls, all the remediation text, the machine probes and overrides, the measurement pass for visitor and logged-in load, all four traffic mixes, and the console commands. Pro adds the load your team puts on the site — concurrent editor saves and the queue work those saves create — plus run history so two projections can be compared. Lite reports a higher ceiling than Pro on the same machine, because it is modelling less load, and every screen says so rather than letting the number stand on its own.
Enormously, and Heat refuses to be quiet about it. With devMode on, Craft logs every query, recompiles Twig and renders a debug toolbar — the same code can be several times slower, and no amount of capacity planning is worth doing until it is off. Both the measurement pass and the report say so in as many words.
It degrades and tells you what it lost. Shared hosts disable things; containers have no /proc/meminfo; macOS has no /proc at all. Every probe is written for the host that says no, absence is reported as absence rather than filled in with a plausible number, and the report names which fields Heat had to infer — because an inferred worker count moves the ceiling further than anything else in the model.
Yes. Connection limits, reserved connections and shared buffers are read on both MySQL/MariaDB and Postgres, and the rest of the model does not care which one you run.

Find Out Before Your Visitors Do

Free Lite edition, or $129 for the load your team puts on the site too.

Craft CMS 5.3+ · PHP 8.2+ · no runtime dependencies