Load and failure
Load Testing a WordPress Site You Can Act On
What a load generator measures and what it hides, why a cold cache and a warm one answer different questions, why p50 hides the failure, and how to read the log.
Two teams run a load test on the same site. The first gets a page that never exceeded 200 ms and reports that the site handles 500 concurrent users. The second gets a run where throughput stopped rising at 68 requests per second, wrote the number down with the settings that produced it, and changed one thing. Both used a real tool against a real install. Only one of them has a number that changes what anybody does next.
You already know from the first module that a benchmark has to repeat, and from the third that a cache hit and a cache miss are different requests. A load test is where those two facts meet: it multiplies one request into thousands and, in the process, destroys most of the context that made the single request meaningful. The work is not producing the number. It is knowing which context you threw away.
A load generator is a scripted concurrent client, and that is all it is
Every load generator, from ab to k6, is a program that opens
connections and sends requests until told to stop. The differences between them are
about the shape of that traffic, not about measurement magic. None of them know
anything about your site.
What they measure is one thing: the time between sending a request and finishing the response, as seen by a client on the same machine or a machine near it. If the client shares a CPU with the server, that time includes the generator competing with PHP for cores. The number the tool prints is a property of the whole path, not of WordPress.
What that hides is a long list, and the first three items matter most. Think time is how long a real visitor spends reading before clicking again; a generator that loops as fast as the server will answer produces far more requests per visitor than any human does. Cache state is whether the object cache and the page cache hold the values the request needs. Database warmth is whether the pages and rows MySQL needs are already in its buffer pool, or whether the first requests have to fetch them from disk.
A useful frame is that a load test answers mechanism questions about a system you have already decided to test. It can tell you the pool of eight PHP workers tops out around a certain rate. It cannot tell you whether that rate is your busiest hour, because your generator has never seen your traffic.
Cold and warm runs are two different experiments, and the disagreement is the finding
Here is the lab. WordPress 7.0.7 on PHP 8.5.6, served by the PHP built-in server with eight worker processes, Redis 8.10 holding the object cache, on a machine with eight cores. One endpoint does about 55 ms of real CPU work and caches the result in the object cache with a read-through that checks, builds, then sets.
Fire 32 requests at it at once with an empty cache, and then again with the value already present. The endpoint reports whether the request had to build the value, so the burst tells you what happened rather than what you hoped happened.
cold, cache empty 32 responses, 9 rebuilt the value
min 308 p50 362 p95 387 max 389 ms
warm, value present 32 responses, 0 rebuilt the value
min 36 p50 143 p95 274 max 287 ms
Both runs sent 32 requests. The warm run's median is 2.5 times lower, and the reason is not that caching is faster in the abstract. The cold run had nine requests computing the same answer at once, and those nine then fought each other for eight cores. The warm run had none.
Run the cold burst three times and you get nine rebuilds every time. That number
is not a property of WordPress; it is a property of how many requests were in flight
before the first one finished, which is bounded by the worker count. Change
PHP_CLI_SERVER_WORKERS and the rebuild count changes with it. A
generator that hides this is answering the warm question while you asked the cold
one.
Throughput stops rising at the knee and latency keeps climbing
Now the same endpoint under a fixed concurrency ramp. Three seconds of warm-up are discarded, then ten seconds are measured at each level, with the same client reusing its connection for the whole run.
| Concurrency | Throughput | p50 | p95 | p99 | Errors |
|---|---|---|---|---|---|
| 1 | 17.0 req/s | 56 ms | 67 ms | 84 ms | 0 |
| 4 | 52.6 req/s | 72 ms | 99 ms | 137 ms | 0 |
| 8 | 68.1 req/s | 113 ms | 158 ms | 217 ms | 0 |
| 16 | 60.0 req/s | 283 ms | 365 ms | 383 ms | 0 |
Up to eight concurrent requests, throughput rises and latency rises slowly. Past eight, throughput goes backwards and latency doubles again for no extra capacity. The machine has eight cores and eight worker processes, so the knee is where supply runs out and everything after it is queueing.
The practical value of a ramp is the knee, not the peak. If your production traffic at 09:00 is 40 requests per second and the knee is 68, you have 1.7 times headroom on this workload, and that is the sentence you can take to a capacity meeting. The peak number from the same table, 68.1 req/s, says nothing about how bad things get at 80, because the table shows they get worse in a way that is not linear.
Latency under increasing concurrency is not a straight line even below the knee. At one worker the p50 was 56 ms and at eight it was 113 ms, which is queueing plus CPU contention, not a change in the work. Never take a single-threaded latency and multiply it by a worker count to predict throughput; you will overestimate by a factor that grows with the pool.
p50 is the statistic that hides the failure, which is why p95 is the one to quote
The generator also produces a full distribution, and the interesting part of it is the tail. In the warm burst above, the p50 was 143 ms and the max was 287 ms. In the ramp at concurrency 16, p50 was 283 ms and p99 was 383 ms. In the run at concurrency 64 against the cached endpoint, throughput was slightly lower than at 16 while p50 went from 41 ms to 229 ms.
| Cached endpoint, concurrency | Throughput | p50 | p95 | p99 |
|---|---|---|---|---|
| 1 | 63.9 req/s | 14 ms | 23 ms | 28 ms |
| 8 | 214.0 req/s | 22 ms | 137 ms | 166 ms |
| 16 | 270.9 req/s | 41 ms | 160 ms | 178 ms |
| 64 | 247.6 req/s | 229 ms | 377 ms | 476 ms |
At concurrency 8 the median cached request is served in 22 ms, which looks like a healthy site, and one request in twenty takes 137 ms. At 64 the median is 229 ms, ten times the single-worker figure, while throughput is no better than at 16. A dashboard built on the median would report a working site while visitors waited a quarter of a second for their first byte.
p50 describes the middle, and the middle of a saturated system is still fast because some requests get a worker immediately. p95 or p99 describes the requests that queued, which is the population that abandons carts. There is a second, worse trap: a median computed over mixed traffic is meaningless when the mix is bimodal. Parse an access log from that same lab session and you get a p50 of 0 ms and a p90 of 80 ms, because most of the logged requests were cached and sub-millisecond while a minority were the expensive ones. Aggregate percentiles across populations hide more than they report.
An honest test fixes four things and writes them down
A test you cannot repeat is an anecdote with a chart. Four things have to be held constant, and the settings belong in the report.
The data set. The same number of posts, the same postmeta row count, the same terms and the same users, seeded the same way. A site with 40 posts and a site with 40,000 are different systems, and autoloaded options grow with plugins, so record the plugin list too.
The concurrency ramp. The same levels, in the same order, for the same duration, with the same warm-up. Fixed concurrency is not the same experiment as a fixed request rate; the first tells you what happens when users pile up, the second tells you whether you can hold a target. Pick one and keep it.
The cache state, deliberately. Decide before you start whether this run is a cold test or a warm one, and make it so. For a cold start, flush the object cache and the page cache before the run and say so. For a warm run, prime the caches with the requests the test will make, then discard the priming traffic. What you must not do is run the test twice in a row and compare the second result with the first, because the first run warmed everything for the second.
The observation point. A generator on the server measures the server. A generator behind a CDN measures the CDN and may never reach the origin at all. Say which one you measured, and if you want the origin number, bypass the edge deliberately.
What the run does to the database
A cold ramp leaves the database warmer than it found it: the buffer pool is populated and the InnoDB statistics have settled. The first level of a ramp is therefore the slowest and least representative. Discard it, or run the whole ramp twice and report the second as warm and the first as cold.
Test a copy of production, and never production
Load testing production is the fastest way to turn a measurement into an incident. The generator competes with real visitors for the same PHP workers, Redis instance and database connections, so the test changes the system it is measuring and can take the site down while it does.
The copy has to match on the things that decide the answer: WordPress version, plugins, PHP version, worker count, and a database of the same shape and roughly the same data. The parts that are easy to get wrong are the object cache, because a copy with no Redis is not a copy, and the traffic mix, because a copy with one page is a microbenchmark.
One more decision belongs here: what counts as failure. A 500 is failure, but so is a p95 that crosses a threshold you chose in advance, and so is a run that produces zero errors because the workers returned a cached 503. Write the threshold down before the first run, so the argument afterwards is about the site rather than about the number.
You already have traffic, and the access log measures it
A generator answers "what would happen if". Your access log answers "what happened", for every visitor you had, with no test to write and nothing to install. What it lacks is the counterfactual: it cannot tell you what the site would have done with a faster database, because you cannot rerun last Tuesday.
For that data to be worth anything, the log needs a duration field, and the common
log format has none. In nginx, $request_time is the time from the first
byte read to the last byte sent, in seconds. In Apache, %D is the same
in microseconds. In the PHP built-in server used in this lab, the endpoint writes the
duration itself as the last field of each line, which is crude and enough.
The fields to get right are the timestamp, the path, the status and the duration. From them you can compute the rate per second, the distribution of response times, the peak second of the day, and which routes consume the most total time. That last one is the question a query log cannot answer, because a route can be fast and numerous or slow and rare, and only the log knows which.
Here is what that looks like on the log from the lab session above, which mixed the heavy endpoint with the cached one and the bursts:
parsed 10270 requests, 0 lines skipped, 92s
mean rate 111.63 req/s
latency ms p50=0 p90=80 p95=105 p99=142 max=201
statuses {200: 10270}
peak second 299 requests at 20:21:42
peak second ms p50=0 p95=2
route count total s p50 p95 max
/m6/heavy 2075 168.8 79 142 201
/m6/cached 8193 3.8 0 1 103
Read that table the way you would read production traffic. Two thousand uncached requests consumed 169 seconds of server time; eight thousand cached ones consumed 3.8 seconds. The mean rate is 112 requests per second, and the peak second is 299, which is 2.7 times the mean and therefore the number to size for. The p50 of 0 ms is the mixed-population trap from earlier: it is the honest median of a distribution whose values are mostly below the resolution of the field.
Write a script that reads a server access log with a duration field and prints four things: the number of requests parsed and the number of lines skipped, the requests per second over the whole file, the p50, p95 and p99 of the response times, and the five routes that consumed the most total time.
Point it at a log from your own site if you can get one, and otherwise at a log you generate yourself. Then answer these, in writing:
Which routes are slow and which are merely frequent, and how does the answer change if you sort by count instead of by total time? What is the peak second, and what would the site have to sustain to keep a p95 under a threshold you choose? And which requests in the log would a synthetic test never have produced?
Show the solution and the reasoning
Write the parser defensively and count what you discard. A real log contains lines that are not requests: PHP warnings, restart banners, health checks, and the occasional stack trace written to the wrong file. A regex that fails silently on those gives you a denominator that is too small and a rate that is too high.
import re, sys
from collections import Counter, defaultdict
from datetime import datetime
LINE = re.compile(
r'(?P<ip>\d+\.\d+\.\d+\.\d+) \S+ \S+ \[(?P<ts>[^\]]+)\] '
r'"(?P<method>[A-Z]+) (?P<path>\S+) [^"]*" '
r'(?P<status>\d{3}) (?P<bytes>\d+) (?P<ms>[\d.]+)'
)
def percentile(values, pct):
ordered = sorted(values)
k = (len(ordered) - 1) * (pct / 100.0)
lo, hi = int(k), min(int(k) + 1, len(ordered) - 1)
return ordered[lo] + (ordered[hi] - ordered[lo]) * (k - lo)
rows, skipped = [], 0
for line in open(sys.argv[1], encoding="utf-8", errors="replace"):
m = LINE.search(line)
if not m:
skipped += 1
continue
rows.append((
datetime.strptime(m["ts"][:20], "%d/%b/%Y:%H:%M:%S"),
m["path"],
int(m["status"]),
float(m["ms"]),
))
rows.sort(key=lambda r: r[0])
span = (rows[-1][0] - rows[0][0]).total_seconds() or 1.0
latencies = [r[3] for r in rows]
print(f"parsed {len(rows)} requests, {skipped} lines skipped, {span:.0f}s")
print(f"rate {len(rows) / span:.2f} req/s")
for pct in (50, 95, 99):
print(f"p{pct} {percentile(latencies, pct):.0f} ms")
peak_second, peak_count = Counter(r[0] for r in rows).most_common(1)[0]
print(f"peak {peak_count} req in {peak_second:%H:%M:%S}")
by_path = defaultdict(list)
for _ts, path, _status, ms in rows:
by_path[path].append(ms)
for path, _ in sorted(by_path.items(), key=lambda kv: -sum(kv[1]))[:5]:
values = by_path[path]
print(f"{path:<28}{len(values):>7}{sum(values) / 1000:>9.1f}s"
f"{percentile(values, 50):>8.0f}{percentile(values, 95):>7.0f}")
The sort that matters is by total time, not by count. On the lab log, the heavy route is both slower and rarer than the cached one, and the cached route would win a count sort by a factor of four while consuming 2 percent of the server time. Optimising by count would send you to the cheap route. The total-time column is the budget: it says where the server's seconds actually went.
On the peak second: it is the number to plan from, not the mean, because capacity is a maximum rather than an average. The mean rate in this log is 112 requests per second and the peak is 299, and a site sized for 112 would fall over at 20:21:42. A reasonable target is to keep the p95 under a threshold you can defend, at the peak rate, with the cache state your site actually has at that hour. Peak hours are often also the hours when a scheduled job runs, so read the log for the job windows before you decide the peak is a surprise.
The requests a synthetic test would never have produced are the interesting ones. In this log they are the cached hits: 8193 of them, all under a millisecond, none of which a generator aimed at the uncached path would ever see. In production they are more likely to be the opposite: an expensive admin-ajax request with no cache, a search with a long tail of terms, a cart endpoint with a session, and a bot crawling a filter combination you never indexed. That is the traffic your log knows about and your generator does not, which is why the honest answer to "how much traffic can we take" is a range with the mix written next to it.
Progress
Mark this lesson done and it stays marked in this browser.