Skip to content

Performance / Field note

Cutting LCP on a WordPress site: what actually matters

The ordered list of what really moves Largest Contentful Paint on a WordPress site, and what only looks like performance work.

Marko Stančić 8 minutes

Field note / Performance

On a WordPress site, Largest Contentful Paint is decided by four things in a fairly strict order: how quickly the server returns the HTML, whether the browser can discover the main image early, whether anything blocks rendering before that image can paint, and how much work the main thread is doing while it tries. Almost everything else sold as WordPress LCP optimisation moves the number only at the margins.

This isn't a case study, and it deliberately carries no before and after numbers. What follows is the ordering: what to look at first, what to do next, and what to stop doing because it only resembles performance work. The only figures here are public thresholds and illustrative examples. Core Web Vitals treats LCP as good at 2.5 seconds or less, as needing improvement up to 4 seconds, and as poor above that, assessed at the 75th percentile of real page loads rather than on a single test run.

Find out which element is actually the LCP element

A large share of the WordPress LCP work we're asked to review was spent on an element that was never the LCP element. Someone compresses the logo, converts the hero to AVIF, moves to a bigger server, and the metric doesn't move, because on that template the LCP element was the heading, the first slide of a carousel that a script inserts after load, or a section background declared in CSS.

The browser will tell you directly. The Performance panel in Chrome DevTools marks the LCP and points at the node, Lighthouse prints the element under its LCP audit, and you can watch it yourself from the console. Enable “Preserve log”, paste this, and reload:

// The last entry wins: LCP can change several times per load.
new PerformanceObserver((list) => {
  const entry = list.getEntries().at(-1);
  console.log(entry.element, Math.round(entry.startTime) + ' ms');
}).observe({ type: 'largest-contentful-paint', buffered: true });

Do this per template, not per site. A homepage, a category archive, a product page and a blog post routinely have four different LCP elements, and a fix that helps one of them is irrelevant on the other three. If the element turns out to be text, the diagnosis changes completely: the blocker is a font and the CSS in front of it, not an image.

Working principle

Identify the element before touching anything. Time spent on a node that isn't the LCP element cannot show up in the metric, however well it was spent.

Time to first byte is a floor, not a factor

Nothing can paint before the HTML arrives, so the server response sets the budget for everything after it. If time to first byte is 1.4 seconds, the entire rest of the page has about a second left to reach the good threshold, and that's before a single image has been requested. Google's own guidance treats a server response under 800 milliseconds as good, and on WordPress that's a hosting and caching question long before it's a code question.

Full page caching removes PHP and the database from the request for logged-out visitors, which is why a cached WordPress homepage on modest hosting can beat an uncached one on an expensive server. The interesting pages are the ones that cannot be served from that cache: cart and checkout, anything behind a login, and faceted search where the query string is part of the answer. On the Innovation Properties Group platform, property search filters by floor area and purpose in the URL, so those views are generated per request, and their response time depends on the database and the object cache rather than on a static file.

That's where a persistent object cache earns its place. Without one, WordPress rebuilds the same option, term and meta lookups on every uncached request; with Redis or Memcached behind it, the repeated work is paid once. Two other things quietly raise the floor on older installs: autoloaded options that have grown to hundreds of kilobytes and are read on every request, and plugins that make outbound HTTP calls for licence checks or feeds while the page renders. A remote call inside the request means your server is only as fast as somebody else's.

Make the hero image cheap and easy to find

When the LCP element is an image, four properties decide almost the whole outcome. Correct dimensions come first: a hero displayed at 1200 pixels wide shouldn't be a 2400 pixel file, and WordPress will happily serve the wrong one if the theme prints a careless sizes attribute next to a perfect srcset. Format comes second: WebP or AVIF for photographic heroes, at a quality level chosen by looking at the image rather than by trusting a plugin default.

Third, the browser has to know the image is important. fetchpriority="high" promotes it ahead of the other things competing for the connection, and the LCP image must never be lazy loaded. Since WordPress 6.3, core tries to do both for you: it skips lazy loading on the first images and assigns high fetch priority to the one it believes is the LCP candidate. The word doing the work in that sentence is “believes”. The heuristic looks at content in the main query, so a hero printed by a template part above the loop, injected by a page builder module, or rendered as a CSS background is regularly missed, and a blanket “lazy load everything” switch in an optimisation plugin will then quietly undo the rest.

// Hero printed by a template part, so core's heuristic never sees it.
echo wp_get_attachment_image( $hero_id, 'hero-wide', false, [
    'fetchpriority' => 'high',
    'loading'       => 'eager',
    'sizes'         => '(min-width: 1200px) 1200px, 100vw',
] );

Fourth, and most often missed, is discoverability. A background image declared in CSS cannot be requested until the stylesheet has been downloaded and the rule has matched, which puts the most important pixel on the page behind the slowest part of the critical path. A real img element in the HTML is discovered by the preload scanner immediately. This is one of the reasons custom WordPress development keeps hero markup in a template we control instead of inside a builder shortcode: you cannot prioritise an element whose markup you cannot see.

Render-blocking CSS and fonts

CSS blocks rendering by design, and a typical WordPress front end fetches a theme stylesheet, the block library, a builder bundle and one file per plugin before it may paint anything. Half of those files are for functionality the current page doesn't use. Dequeuing a stylesheet on templates that don't need it is unglamorous work with a direct effect on when the LCP element is allowed to appear, and it beats minifying files that shouldn't be requested at all.

Fonts deserve their own pass, especially when the LCP element is text. With the default font-display: block, the text is invisible while the font loads, and LCP is recorded when it finally paints, so a slow font file becomes the metric. swap shows the fallback immediately and accepts the reflow; optional avoids the reflow and lets some visitors keep the fallback for that visit. Self-host where you can, preconnect where you can't, and preload only the exact woff2 files used above the fold: every extra preload competes with the hero image for the same connection, which is how a page ends up slower after being “optimised”.

Deferring JavaScript is not the same as removing it

Adding defer to every script is the most common single action taken in the name of Core Web Vitals on WordPress, and it's worth being precise about what it buys. Deferring keeps a script out of the parser's way, which is real. It doesn't stop the file being downloaded on the same connection as your hero image, and it doesn't stop it executing on the main thread that the browser needs for layout, decode and paint. Deferred work is moved, not deleted.

Removal is the stronger move, and it starts with one question per script: does the first screen need this? Chat widgets, review badges, map embeds, carousels below the fold and most analytics do not. Loading them on interaction or on idle isn't a trick, it's an accurate description of when they're needed. Cookie banners deserve a manual check: they often render above the hero, sometimes push it down, and occasionally become the LCP element themselves. jQuery is usually why a site can't simply defer everything: inline handlers printed by plugins expect it to be ready, so removing the dependency is a refactor, not a checkbox.

The ordering is the skill. Anyone can apply the list.

A CDN belongs in the same category of partial help. It shortens distance, so a visitor in another country stops paying for a round trip across an ocean on every asset. It does nothing about weight: an oversized hero is oversized from the edge too, and a CDN that only serves static files doesn't change how long PHP takes to build the HTML. A CDN with full page caching at the edge does improve time to first byte, which is a different product from the one most sites buy.

Trust field data, not a single lab run

Every number produced by a one-off Lighthouse run is a lab measurement, made on a simulated device with a throttled connection, and it varies between runs on the same unchanged page. That makes it a good diagnostic and a poor verdict: excellent at naming the LCP element and what blocks the render, unreliable as proof that a release helped.

The numbers worth acting on come from your own visitors: the Chrome UX Report, surfaced in Search Console's Core Web Vitals report or in PageSpeed Insights above the lab section, and your own field measurement if you add the web-vitals library. Read it knowing what it is: a rolling window at the 75th percentile, grouped by URL where traffic allows and by origin otherwise. It lags, so a change shipped this week won't appear in it tomorrow, and one fast template won't rescue an origin whose real traffic lands somewhere slower.

None of this produces a score to frame. It produces an order of operations, which is more useful, because the same score on two WordPress sites can call for opposite work: one needs a caching layer and a hosting conversation, the other a lighter hero image and one stylesheet dequeued from a template. Knowing which of those you're looking at is the whole job.

Practical takeaway

Identify the LCP element per template, fix the server response first, then make the hero cheap and discoverable, then clear the render path. Verify in field data, not in a single lab run.

View journal index

Apply the thinking

Let's examine the real constraint

If performance, architecture or maintainability is slowing the product down, bring the evidence. We can define what should change first.

Discuss your project