A real client site. A real 6.2-second load. Eleven steps, documented in the order we did them — including the two that made things worse before they made them better, and the one everybody skips that turned out to matter most.
The site was a mid-sized publisher: about 4,200 posts, a WooCommerce store bolted on for merchandise, six years of accumulated plugins and three developers’ worth of inherited decisions. The brief we received said “we need a CDN”. The brief was wrong, which is normal. People arrive with a solution rather than a symptom because someone told them the solution on a podcast.
What follows is the actual sequence, with the measurement after each stage. Nothing here is theoretical; every number is from this site.
The patient: 6.2 seconds and a bad diagnosis
Initial field data, taken from real users over the preceding 28 days:
| Metric | Mobile | Desktop | Target |
|---|---|---|---|
| Largest Contentful Paint | 6.24 s | 3.81 s | < 2.5 s |
| Interaction to Next Paint | 486 ms | 210 ms | < 200 ms |
| Cumulative Layout Shift | 0.34 | 0.29 | < 0.1 |
| Time to First Byte | 1.41 s | 1.38 s | < 0.6 s |
Note the TTFB. It is 1.41 seconds before a single byte of HTML arrives, which means no amount of front-end work can get LCP under 2.5 s. A CDN would have cached static assets and left that 1.41 s almost untouched. This is why the order of operations matters more than the list of techniques.
Front-end optimisation on a slow server is painting a house with wet walls. Until TTFB is under about 600 ms there is a hard floor on LCP that no amount of image compression will get you under. Server first, always.
Step 1 — Measure before you touch anything
Three things get recorded before any change, and all three go in a document with a date on it:
- Field data from real users, segmented by device and by page template. Site-wide averages hide the fact that your product pages are fine and your category pages are catastrophic.
- A server-side trace of one uncached page load: total PHP time, number of database queries, slowest query, peak memory.
- A waterfall from a throttled connection, because everything looks fast on office fibre.
Our trace of the homepage: 1,847 database queries, 1.19 s of PHP execution, 214 MB peak memory. For context, a healthy WordPress page is 30–80 queries. Eighteen hundred is a defect, not a tuning opportunity.
Step 2 — Fix the server before the front end
Three server-level changes, in ascending order of effort:
PHP version
The site ran PHP 7.4, which had been unsupported for some time. Moving to PHP 8.4 required fixing four deprecation warnings in the theme and replacing one abandoned plugin. The payoff was immediate: PHP execution time fell 41%, from 1.19 s to 0.70 s, with no other change. This is the single highest return-per-hour action available on most legacy WordPress sites.
OPcache configured, not merely enabled
OPcache was on with default settings, which meant it was evicting compiled scripts constantly because the memory allocation was too small for a site with this many plugins. Raising opcache.memory_consumption to 256 MB and opcache.max_accelerated_files to 20,000 removed the thrashing. Another 120 ms.
Hosting that is not oversold
The site was on shared hosting whose TTFB varied from 400 ms to 2.8 s depending on the time of day — a classic noisy-neighbour signature. Moving to a container with guaranteed CPU allocation cost an extra £22 a month and stabilised TTFB more than any software change we made.
Upgrading PHP on a legacy WordPress site returns more performance per hour of work than anything else we do. If you are on PHP 7.x in 2026 you are leaving roughly 40% of your server performance unused — and running unsupported software.
After step 2: TTFB 1.41 s → 0.62 s. LCP 6.24 s → 4.41 s. No front-end work yet.
Step 3 — Find the slow queries
1,847 queries needed explaining. We enabled the MySQL slow query log with a 50 ms threshold and ran a profiling plugin on staging. Three findings, which between them accounted for most of it:
- An unindexed
meta_keylookup in a “related products” feature, executing a full table scan of a 2.1-million-rowpostmetatable on every page load. One composite index: 340 ms saved per request. - A plugin querying inside a loop — one query per post to fetch a custom field, 24 posts per page, on three widgets. Replaced with a single primed meta cache before the loop: 71 queries removed.
- Autoloaded options bloat. The
wp_optionstable held 3.4 MB of autoloaded data, much of it transients left behind by plugins that had been deleted years earlier. Every single request loaded all of it.
-- Find your autoload offenders
SELECT option_name, LENGTH(option_value) AS size_bytes
FROM wp_options
WHERE autoload = 'yes'
ORDER BY size_bytes DESC
LIMIT 25;
Anything above ~100 KB in that list deserves investigation. Anything belonging to a plugin you no longer have installed can go.
The autoloaded-options query above takes two seconds and reveals problems on roughly half the sites we look at. Sites that have been running for more than three years almost always have megabytes of orphaned transients loading on every single request.
After step 3: 1,847 queries → 94 queries. PHP time 0.70 s → 0.31 s.

Step 4 — Object caching done properly
WordPress caches objects in memory for the duration of a single request and then throws them away. A persistent object cache — Redis or Memcached — keeps them between requests, which is transformative for logged-in users and for any page that cannot be page-cached.
This is the step that went wrong first. Our initial Redis deployment used a single database shared with another application, and key collisions produced occasional stale output that took two days to diagnose. Lessons that are now in our runbook:
- Give each site its own Redis database index and a unique key prefix.
- Set
maxmemory-policytoallkeys-lru. The default will refuse writes when full rather than evicting. - Allocate real memory. Our 4,200-post site needed 512 MB; the initial 128 MB caused constant eviction and a cache hit rate of 61%.
- Monitor the hit rate. Below 90% means something is wrong — usually memory or a plugin flushing the whole cache on every save.
Once configured correctly: cache hit rate 97.2%, uncached page generation 0.31 s → 0.14 s, and logged-in admin pages went from painful to instant.
Step 5 — Page caching and the rules that break it
Full-page caching is the largest single win available and the easiest to get subtly wrong. The mechanism is simple: store the finished HTML, serve it to the next visitor without running PHP at all. The difficulty is entirely in the exclusions.
| Must never be cached | Why |
|---|---|
| Cart, checkout, account pages | Per-user content; caching leaks one customer's data to another |
| Logged-in sessions | Personalised menus, admin bar, draft content |
| Requests with a cart cookie set | Stale basket totals are worse than a slow page |
| Search result pages | Unbounded cache-key space; fills the cache with single-use entries |
| Anything with a nonce in the HTML | Cached nonces expire and silently break forms |
We also set a sane invalidation policy rather than the common “purge everything on any change”, which on a busy site means the cache is perpetually cold. Publishing a post now purges that post, its archives, the homepage and the sitemap — not the other 4,199 pages.
The most serious production incident we have seen in WordPress performance work was a page cache configured without a cart-cookie exclusion. Customers saw each other's basket contents, including names and partial addresses. Test your exclusions on staging with two real sessions before you go live. Every time.
After step 5: cached TTFB 0.62 s → 0.11 s. LCP 4.41 s → 2.92 s.
Step 6 — The plugin audit
The site had 47 active plugins. We measured each one's cost by deactivating it on a staging clone and re-running a ten-request trace. The results were, as usual, extremely uneven:
| Plugin category | Count | Cost per request | Action |
|---|---|---|---|
| Page builder + addons | 4 | 410 ms | Kept — rebuild scheduled separately |
| Social sharing (two competing plugins) | 2 | 180 ms | Replaced with 30 lines of theme code |
| Related posts (database-driven) | 1 | 290 ms | Replaced with a cached taxonomy query |
| Analytics wrappers | 3 | 95 ms | Consolidated into one |
| Abandoned / no longer updated | 6 | 140 ms | Removed entirely |
| Slider plugin used on one page | 1 | 75 ms site-wide | Conditionally loaded |
Total removed: eleven plugins and roughly 620 ms per uncached request. Note the slider — a plugin used on exactly one page was loading its CSS and JavaScript on all 4,200. Conditional loading is often more valuable than removal, because it keeps the feature.
Clone to staging. Record ten uncached loads with a profiler. Deactivate one plugin. Record ten more. Reactivate. Repeat. It takes an afternoon for forty plugins and it replaces every argument about which plugin is “heavy” with a number.
Step 7 — CSS: the render-blocking problem
The site shipped 412 KB of CSS across 14 separate stylesheets, every one of them render-blocking. The browser cannot paint until it has all of them.
What worked, in order of impact:
- Inline the critical CSS for above-the-fold content (about 9 KB) directly in the
<head>, and load the rest asynchronously. LCP improved by 640 ms on mobile immediately. - Remove unused rules. A coverage analysis showed 71% of the CSS was never applied on a typical article page — mostly page-builder widget styles for widgets this template never uses.
- Stop combining files blindly. The previous developer had merged all 14 into one 412 KB file to “reduce requests”. Under HTTP/2 that advice is a decade out of date, and it meant every page downloaded every rule. Splitting by template beat combining.
Automated critical-path tools generate their CSS from one snapshot of one viewport. Change the template and the inlined CSS silently goes stale, producing a flash of unstyled content that only some visitors see. Regenerate on deploy, and check the output on a real phone.
Step 8 — JavaScript: load order over file size
JavaScript hurts differently from CSS. It does not merely delay paint; it occupies the main thread, and an occupied main thread cannot respond to taps. That is what INP measures, and INP is where this site was worst.
Three changes, no file-size reduction involved:
deferon everything non-critical. Notasync—deferpreserves execution order, which matters when scripts have dependencies. Nineteen scripts moved.- Break up long tasks. One analytics initialiser ran a 340 ms synchronous block on load. Yielding to the main thread between chunks took the longest task to 48 ms.
- Load on interaction. The comment system, the share widget and the search autocomplete now load when the user shows intent, not on page load. Together they were 180 KB of JavaScript that most visitors never used.
INP: 486 ms → 174 ms. The single biggest contributor was breaking up that one long task — a 4 KB change in a 900 KB bundle.

Step 9 — The image pipeline
Images were 68% of page weight. They usually are.
| Change | Detail | Effect |
|---|---|---|
| Modern formats | AVIF with WebP and JPEG fallbacks | −54% image bytes |
| Correct dimensions | Stop serving 2400 px files into 720 px slots | −31% on top of the above |
| Lazy loading below the fold | loading="lazy", but never on the LCP image | −1.1 s to first paint |
| Explicit width and height | On every single image tag | CLS 0.34 → 0.04 |
| Preload the LCP image | With fetchpriority="high" | −380 ms LCP |
It is the most common self-inflicted performance wound we see. A blanket lazy-load applied to every image delays the one image the metric is actually measuring, and typically adds 800–1,500 ms to LCP. Exclude the hero image explicitly, and give it fetchpriority="high".
Step 10 — Fonts, the quiet LCP killer
The site loaded five font families in nine weights from a third-party service. Each one required a DNS lookup, a TLS handshake and a request chain before any text could render in its intended face.
What we did, and would do again on any site:
- Self-host. Removes an entire third-party connection from the critical path. Worth 200–400 ms on a cold mobile connection, every time.
- Subset aggressively. The site is English-only; the fonts shipped Cyrillic, Greek and Vietnamese glyphs. Subsetting cut each file by roughly 70%.
- Cut to two families, four weights. Nobody noticed. Nobody ever notices.
font-display: swapso text is visible immediately in a fallback face.- Preload only the two faces used above the fold. Preloading all of them is the same as preloading none.
−510 ms LCP on mobile. This step is routinely skipped and routinely one of the three biggest wins available.
Step 11 — Third parties and the honesty conversation
Remaining on the page: two analytics platforms, a heat-mapping tool, a chat widget, an advertising script and two social embeds. Together they accounted for 1.4 s of main-thread time and a 620 ms contribution to LCP on mobile.
This is not a technical problem. It is a conversation with the people who added each tag, and the only useful framing we have found is: what decision did this tool inform in the last quarter, and what would we lose if it were gone tomorrow?
Outcome on this site: heat-mapping removed (unused for fourteen months), one of the two analytics platforms removed (duplicate data), chat widget deferred until user interaction, social embeds replaced with static images linking out. The advertising script stayed, because it pays for the site — but it moved to an asynchronous, sandboxed load.
Before adding any tag, ask what decision it will inform and who will look at it. Before keeping one, check when someone last opened its dashboard. On this site, two of six failed that test immediately and nobody missed them.
Final numbers and what actually mattered
| Metric | Before | After | Change |
|---|---|---|---|
| Largest Contentful Paint | 6.24 s | 0.94 s | −85% |
| Interaction to Next Paint | 486 ms | 112 ms | −77% |
| Cumulative Layout Shift | 0.34 | 0.02 | −94% |
| Time to First Byte | 1.41 s | 0.11 s | −92% |
| Page weight | 4.8 MB | 0.9 MB | −81% |
| Database queries (uncached) | 1,847 | 68 | −96% |
Ranked by contribution to the final LCP figure, the eleven steps landed like this:
- Page caching — the single largest win, once the server could generate a page quickly enough to cache.
- Server and PHP version — unglamorous, cheap, and a prerequisite for everything after it.
- Query and index work — invisible from the front end and worth more than every asset optimisation combined.
- Fonts — the step people skip.
- Critical CSS — large gain, moderate ongoing maintenance cost.
- Images — biggest byte reduction, smaller time reduction than the byte count suggests.
Nobody arrives asking for a database index. They arrive asking for a CDN, because a CDN is a thing you can buy. The work that matters is usually the work nobody sells. Priya Raghunathan, Web Performance Editor
Two final notes. First, the CDN the client originally asked for was eventually added — as step twelve, worth about 90 ms. It was the right tool for the wrong problem. Second, none of this holds without maintenance: six months later we found the plugin count back up to 34 and the autoloaded options creeping past 1 MB. Performance is a habit, not a project.
If you want this done on your site, our WordPress service runs exactly this sequence, and the technical audit produces the prioritised backlog it starts from. The companion piece to this article is our Core Web Vitals guide, which explains how the metrics above are actually collected.
Questions readers keep asking
Less important than how it is configured. Any of the mainstream page-cache plugins will get you most of the way if the exclusions are right and invalidation is sensible. What matters far more is whether you also have a persistent object cache, whether your host does page caching at the server level (faster than any plugin), and whether your PHP version is current. We have seen a well-configured free plugin comfortably beat a badly configured premium one.
If you have meaningful traffic from outside your server's region, yes — physics does not negotiate. If your audience is local and your TTFB is already 110 ms, a CDN is worth perhaps 50–100 ms and is not where your next hour of work should go. Fix the server, the queries and the fonts first; add the CDN when it is the largest remaining item.
Not always, but it is always a cost. On this site the builder added around 410 ms per uncached request and a great deal of unused CSS. That can be acceptable if it is what lets a non-technical team publish without a developer. The failure mode is using a builder for pages that are structurally identical — a custom block or template is faster to render, faster to load and easier to change consistently.
Monthly for field data, and on every deployment for the lab checks. Sites do not stay fast; they drift. Six months after this project the plugin count was climbing again and autoloaded options had crept past a megabyte. A five-minute monthly check catches that while it is still five minutes of work.
Directly, a little — page experience is a real but modest ranking input, and it matters most as a tie-breaker between comparable results. Indirectly, a great deal: faster pages get crawled more efficiently, keep more visitors, and convert better. On this site organic traffic rose 22% over the following quarter, and we would attribute more of that to visitors staying than to any ranking change.