Your WordPress Site Is Slow and the Checklist Came Back Green

No Comments

Photo of author

By Liu Yu

When a WordPress site is slow, the advice you find is a list: too many plugins, no caching, unoptimised images, a bloated database, cheap hosting. The lists are not wrong. The problem is what happens when you work through one and every item comes back green — caching on, images fine, database “optimised” last week — and the site is still slow.

That is the case worth writing about, because it has a pattern. Standard checklists are built to find many small things. The expensive failures we actually get called in for are usually one large thing, and one large thing is invisible to a checklist that counts.

A worked example: every metric was green

A server we manage went to 91% disk usage with 1.1 GB of free memory and 3 GB of swap consumed. The obvious reading was capacity: too many sites on one box, time to upgrade.

We checked the usual WordPress-level suspects on the sites involved first. All of them looked fine:

  • Autoloaded options: 134 KB — well inside normal.
  • Expired transients: zero.
  • Overdue cron events: zero.
  • SELECT 1 against the database: 0.26 ms.
  • Redis GET: 0.01 ms.
  • Peak PHP memory per request: 32 MB.

By any checklist, there was nothing to fix. The actual cause was a single row in one site’s wp_options table:

_transient_<plugin>_queried_products_<hash>   →   30.26 MB
serialized payload: a:689723:{ … }

One option row. 30 megabytes. 689,723 array elements. The site it belonged to had nineteen products.

How one row does that much damage

A WooCommerce AJAX product filter plugin was caching the result of every filter combination visitors generated, and appending each new result into the same transient rather than storing them separately. The TTL was hardcoded to thirty days, so nothing expired on its own.

The chain from there is mechanical:

  1. Every filtered page view reads a 30 MB row and unserializes 689,723 elements into PHP memory.
  2. It then writes the whole row back. We measured that UPDATE at 6.38 seconds, with 2.62 seconds of lock time. What that lock blocks depends on the storage engine and the statement’s access path — on InnoDB it is row and index level, not a whole-table freeze, so treat “everything waits” as a claim to verify on your own install rather than a general rule. What is not in doubt is that a multi-second write repeated on every filtered page view is enough to queue requests behind it.
  3. MySQL’s slow query log records the full statement. A 30 MB statement means one slow query is roughly 30 MB of log.
  4. The slow log grew to 32 GB, which was 22% of the disk on its own.
  5. The fuller the disk, the worse the I/O, the slower the queries, the more log. It compounds.

The memory pressure was the same story: unserializing that array on every request, plus a generous tmp_table_size, accounted for the 8 GB that looked like “we need a bigger server”.

Why the checklist missed it

Look again at the metrics that came back green. Not one of them measures the size of an individual row.

“Autoloaded options: 134 KB” is the check everyone knows, and it was genuinely fine — because this row was not autoloaded. It was loaded on demand, by exactly the pages people were using. The most-cited database health metric in WordPress is blind to the single most expensive row in the table.

Same with transients. “Expired transients: zero” was true. The problem transient had not expired; it had thirty days left on a TTL that was reset every time it grew.

This is the general shape of it: checklists sum, and a sum hides an outlier. A table with 4,000 healthy rows and one 30 MB row has a perfectly reasonable average.

The query that finds it

Two statements, and they take a second. Run them before you conclude anything about capacity:

-- The ten largest individual option rows
SELECT option_name,
       ROUND(LENGTH(option_value)/1024/1024, 2) AS mb,
       autoload
FROM wp_options
ORDER BY LENGTH(option_value) DESC
LIMIT 10;

-- Total autoloaded weight
SELECT ROUND(SUM(LENGTH(option_value))/1024/1024, 2) AS autoload_mb
FROM wp_options
WHERE autoload IN ('yes', 'on', 'auto', 'auto-on');

A note on that second query, since this article is about misleading metrics and it would be poor form to hand you one. WordPress 6.6 changed the autoload column from a simple yes/no to a set of values, adding on, off, auto, auto-on and auto-off so that core can decide automatically for large options. Most advice still written on the internet filters on autoload = 'yes' alone, which silently under-reports on any modern install. Check what values your own installation actually uses before trusting either version.

Read them together, not separately. The second query is the familiar health check — over 1 MB autoloaded is genuinely a problem worth fixing. But the first query is the one that finds the outlier, and it is the one almost nobody runs, because the well-known advice is about autoload totals.

On a multi-site server, run the first query against every database. The row that takes down a box does not have to belong to the site you were asked to look at.

Reading the slow query log correctly

A 32 GB slow log invites the wrong conclusion — that the site is drowning in slow queries. Before you act on that, check how big a single record is rather than how many records there are:

head -c 60000000 /path/to/slow.log | grep -c "^# Time:"

In this case a 58 MB sample contained three entries. Three. That is not “a flood of slow queries”, it is “a handful of enormous statements”, and the two point at completely different investigations. One sends you tuning indexes; the other sends you looking for a giant row.

Two operational notes that cost us time to learn:

  • Truncate the log, do not delete it. rm on a file MySQL still holds open does not return the disk space — the file descriptor keeps it allocated until MySQL restarts. Use truncate -s 0.
  • Configure rotation immediately afterwards, with copytruncate and a size cap. A log that grew to 32 GB once will do it again.

Fixing it without editing the plugin

The temptation is to patch the plugin’s caching code. Do not — the next update overwrites it and the problem returns months later with nobody remembering why.

Well-built plugins expose filters for exactly this. The one here offered a filter to suppress its cache entirely and another to change the transient duration. We used a must-use plugin with two controls rather than one:

  • Cut the TTL from thirty days to one.
  • Add a hard size ceiling — a shutdown hook that deletes the transient when it exceeds a couple of megabytes.

The TTL alone is not enough. A row that reached 30 MB in thirty days can still reach several MB in one, and you would be back to a slow lock, just less often.

Be precise about what the size check buys you, though. Deleting an oversized transient on shutdown is after-the-fact cleanup: the read, the unserialize and the write have already happened for that request, and a concurrent request can rebuild the row before the next check runs. It caps how long the site stays in the bad state; it does not prevent the bad state. A true ceiling has to refuse the write at the point the plugin stores it, which usually means the plugin’s own filter rather than a shutdown hook.

Then verify the feature still works, which is a step people skip because the site loads. A category page returning 200 proves nothing about whether filtering still returns products. Count them:

curl -s "https://example.com/product-category/widgets/?filter=blue" \
  | grep -o "product-item-class" | wc -l

Use grep -o … | wc -l rather than grep -c. grep -c counts matching lines, and minified HTML puts the whole listing on one line — nineteen products would come back as 1. For anything you actually depend on, count DOM nodes in a headless browser instead of matching strings.

Check that the filter widget is still rendered too. Suppressing a cache incorrectly can leave you with a page that loads fast and filters nothing.

Two more cases where the symptom pointed the wrong way

The pattern is not specific to databases. Two others from the same period, both of which sent us down a wrong path first:

Every WordPress metric was fine; the cache was being bypassed by a cookie

A multilingual site had a time to first byte of four to six seconds, degrading past thirty seconds under mild concurrency, with occasional 503s from the cache layer. Static files were instant and showed cache hits, so the network was fine.

The cause was one response header. The multilingual plugin sets a language cookie on every page response, and a cache layer that sees Set-Cookie treats the response as personalised and refuses to store it. Every request was therefore going through to PHP, which needs one to two seconds to boot and initialise plugins, and under concurrency the workers queue up until the cache gives up waiting.

The fix was a single constant defined before the plugin loads, disabling the cookie. It is safe only when language is determined by the URL path rather than by the cookie, so check that first. Measured on the same site, same day:

Measurement Before After
Cache status, 5-6 consecutive requests MISS every time HIT every time
Home page TTFB 4.6 – 5.8 s 0.74 – 1.01 s
TTFB at concurrency 6 15 / 25 / 25 / 30 / 33 / 36 s 0.77 – 2.01 s
Concurrency 10 not attempted 0.77 – 1.84 s

No code was optimised. Nothing was cached that was not already meant to be cached. One header was removed and the existing cache started doing its job.

HTTP was instant while HTTPS timed out completely

A site stopped responding. Load average 0.37, memory free, database up, PHP workers running — every system metric normal. Port 80 answered in 0.0003 s. Port 443 timed out at 12 seconds, including from the server’s own loopback interface.

Connection state told the story: 1,510 total, 366 established. A batch job of ours had been making concurrent HTTPS requests through a proxy that timed out without releasing connections, exhausting the web server’s SSL connection pool. A graceful restart of the web server cleared it in seconds with no data loss.

Two things worth taking from that one. First, split the check by protocol before concluding the site is down — “the site is unreachable” and “the site is unreachable over HTTPS” are different faults. Second, and more embarrassing: our first diagnosis was wrong because our own requests were going through a local proxy that was flapping. Any “the site is down” conclusion has to be re-tested with the proxy bypassed and from the server itself before you touch anything.

An order of operations that assumes the checklist already passed

When the standard advice has come back clean and the site is still slow:

  1. Look for the outlier, not the total. Largest individual option rows, largest tables, largest single files. Across every database on the server, not just the one you were asked about.
  2. Check whether the cache is being used at all, rather than whether it is installed. Request the same URL a few times and read the cache header. A Set-Cookie on an otherwise cacheable page is enough to disable it entirely.
  3. Separate the layers before believing a symptom. HTTP versus HTTPS. From outside versus from the server’s loopback. With and without your own proxy. Most wrong diagnoses we have made started by skipping this.
  4. In logs, measure record size before record count. A huge log file usually means huge statements, not many of them.
  5. Bound the failure, do not just slow it down. A TTL reduces frequency; a hard size ceiling is what prevents recurrence.
  6. Verify the feature, not the status code. Count the products, submit the form, load page five of the pagination.

None of this replaces the standard checklist — plugins, caching, images and hosting really are the usual causes, and you should rule them out first. It is what to do on the day they all come back green.

Two related failures worth reading if you are working through a slow or broken site: what to do when the page is blank rather than slow, and the things that break quietly after a host migration — server moves are where a lot of these conditions get created.

We do this kind of diagnosis as part of ongoing WordPress support, mostly for export businesses running on their own servers. If a site is slow and the obvious causes have already been ruled out, send us the URL.

Leave a Comment