Why is your WordPress site slow? A case we actually measured

When we meet a slow site, the first sentence is always the same: “well, it’s WordPress, it’s bloated.” Usually that is not true. Below is what we found on a real client site, which measurements we took, and what worked — and what did not.

Measure first, then touch

FD Art Gallery is an art gallery and shop running on WooCommerce. The complaint was plain: the cart page will not open. We measured it — 2.9 seconds. The home page, meanwhile, was fine. That split is a useful clue: the problem is not across the whole site, it is on the pages that cannot be cached.

The reflex here is usually “let’s get a bigger server”. Instead we worked out where the page was spending its time, profiling with a temporary mu-plugin:

  • Database: 21 queries, 14 ms — about 1% of the total.
  • Total time: 2,761–3,330 ms.
  • Peak memory: 186 MB.

The database was innocent. The time was going into PHP itself.

The real cause: OPcache had never been configured

PHP keeps compiled code in OPcache so it does not recompile source files on every request. On this server OPcache had never been configured — it was running on PHP’s defaults: 128 MB of memory, a 10,000 file limit. The box held four WordPress sites, each loading roughly 3,240 files per request.

The measurement said:

opcache: memory 128/128 MB FULL · hit rate 22% · misses 106,213

The signature was this: the staging copy — same code, same 12 plugins, same data — opened in 1.2 seconds with 38 MB peak memory, while production took 2.9 seconds and 186 MB. That memory gap between two identical environments is the clearest sign that OPcache is full.

The configuration we applied

opcache.memory_consumption = 768   ; 512 was tried first and filled at 509/512
opcache.interned_strings_buffer = 32
opcache.max_accelerated_files = 50000
opcache.validate_timestamps = 1
opcache.revalidate_freq = 2

This setting is PHP_INI_SYSTEM: it cannot be set per pool and affects every site on the server. All four sites had the same owner, so we asked and applied it.

The result — measured the same way

beforeafter
/cart/2.9–3.2 s0.94–0.97 s
/my-account/2.6–2.9 s0.74–0.95 s
/checkout/1.7–2.0 s0.40–0.48 s
peak memory186 MB34 MB
OPcache hit rate22%89%

Visitors from ads were getting the slowest experience

The second finding was sneakier. Ad and newsletter links append parameters like ?utm_source=…, gclid and fbclid. The cache treats each of those as a different page and misses every one. In other words your most expensive traffic was seeing the uncached, slowest version.

We stripped those parameters from the cache key. TTFB dropped from ~2.3 seconds to 15–25 milliseconds.

The experiment that failed: preloading the LCP image

The mobile score was poor and Lighthouse said “LCP image not preloaded”. The hero image was printed by Elementor as an inline background-image at character 74,856 of the HTML, while </head> ended at 25,246. CSS backgrounds are invisible to the preload scanner, so the browser discovered the image very late. It was a reasonable target.

We wrote a mu-plugin that printed the preload tag into <head>, then measured:

beforewith preload
LCP (mobile)12,211 ms12,309 ms
Score4839

No gain — the score actually dropped. The bottleneck was not downloading the image but running JavaScript: Style & Layout 4.3 s, jQuery 4.8 s. Fetching a 287 KB image at high priority also has a cost. We reverted it.

What is left is a design decision, not a code one

On desktop the work was done (score 85, LCP 2.0–2.3 s). On mobile the page was still 3,358 KB across 111 requests: images 1,332 KB, the Turnstile widgets alone ~1 MB, elementor-all-widgets.min.js 135 KB with 82% unused, and 99% of woocommerce-all.min.css unused.

No server setting fixes that. With 33 containers and 60 widgets on the home page you cannot cut the Style & Layout cost from the code side; the page has to get simpler. From there on it is the client’s content and design call — and that is what we told them.

What to take away

  1. Do not touch anything before you measure. Had we started from “the database must be slow”, we would have optimised queries and gained nothing.
  2. Server settings fail quietly. A full OPcache raises no alarm. The same code using different memory in two environments is a strong clue.
  3. Think about the cache for ad traffic too. If tagged URLs bypass it, your most valuable visitor sees your slowest page.
  4. Revert what did not work, and write it down. An “improvement” with no measurable gain is just technical debt.