Skip to main content
Server & DevOpsAugust 25, 20265 min read

How to Cut LCP With an Image Pipeline Without Touching the Theme

On most content and commerce pages the Largest Contentful Paint element is a single image, which means one image decides the score. This guide covers the decisions that belong to the delivery layer rather than to a template, from format negotiation on the Accept header to the size actually credited by the metric, the priority the browser assigns to an image before layout, and the one attribute that quietly ruins a hero. Every attribute here is checked against the HTML standard or web.dev, including which images must never be lazy loaded.

On most content and commerce pages the Largest Contentful Paint element is one image. Fix how that image is chosen, sized, prioritised and delivered and the score moves, whatever framework rendered the page around it. Almost none of those decisions have to live in a template.

Know which element you are actually optimising

The metric only considers a short list of elements, which was kept deliberately small. Images, an image inside an svg, video elements, an element with a background image loaded through url(), and block level elements containing text. Good is 2.5 seconds or less, judged at the 75th percentile of page loads, with mobile and desktop measured separately.

One detail in that definition pays for itself immediately. For an image resized from its natural dimensions, the size credited is the visible size or the intrinsic size, whichever is smaller. Shipping a 3000 pixel wide hero into an 800 pixel slot buys you nothing in the metric and costs your visitor the difference in bytes.

Split the number before you tune it

web.dev breaks LCP into four parts with rough targets. Time to first byte at around 40 percent, resource load delay under 10 percent, resource load duration at around 40 percent, and element render delay under 10 percent.

Measure the split before you touch anything. A slow origin and a bloated hero image are both common, they need opposite fixes, and the four part breakdown tells you which one you have in about a minute.

Negotiate the format at the edge

Browsers already tell you what they can decode. A current browser requesting an image sends an Accept header along the lines of image/avif,image/webp,image/png,image/svg+xml,image/*;q=0.8,*/*;q=0.5, so the server can pick the format without any markup change at all.

# /etc/nginx/conf.d/avif-negotiation.conf
map $http_accept $avif_suffix {
    default        "";
    "~*image/avif" ".avif";
}
# inside the server block that serves your media
location ~* ^/media/.+\.(jpe?g|png)$ {
    add_header Vary Accept;
    try_files $uri$avif_suffix $uri =404;
}

You pre-generate hero.jpg.avif next to hero.jpg, and a browser that advertises AVIF gets it. When the map yields an empty string the first candidate is just the original path, so the fallback needs no extra branch.

Two things make or break this. Check that your nginx ships the AVIF type with grep avif /etc/nginx/mime.types, because without it the file goes out with the wrong content type. And keep the Vary header, since it is what tells every cache between you and the visitor that this URL has more than one correct answer. The same Vary value has to appear on every response for that URL, including 304 responses.

Give priority to exactly one image

Images start life at low priority and are raised to high at layout time if they turn out to be in the viewport. That promotion is real work that happens after layout, and it is time your hero spends waiting.

<img src="/media/hero.jpg" width="1200" height="675" fetchpriority="high" alt="">

The fetchpriority attribute takes high, low or auto, and high on the LCP image is the documented use. The discipline is the point. Mark one image high per page, and consider low for the carousel slides that are not on screen yet, so the browser spends its first connections on the one that counts.

If the image is already preloaded as one of the first things in the head, adding high priority to the tag will not change much on top of it. Preloaded images should carry fetchpriority="high" on the preload itself, otherwise the preload inherits the default priority for its as value.

The attribute that quietly ruins a hero

Lazy loading is the single most common self-inflicted LCP wound, usually installed by a plugin that applies it to every image on the page. The guidance from web.dev is direct. Do not lazy load images likely to be in the viewport at load, especially the LCP image, and use loading="lazy" only for images outside the initial viewport.

Two supporting rules travel with it. Always set width and height, since an image with no dimensions is laid out as zero by zero, which both invites layout shift and can convince the browser that an entire gallery fits on screen. And for images you do lazy load, sizes="auto" lets the browser use the element's own laid out width to pick from srcset, which saves maintaining the same breakpoints twice. It is valid only alongside loading="lazy", and where it cannot be resolved the browser falls back through the rest of the sizes list.

When the hero lives in CSS

A background image set in a stylesheet is invisible to the preload scanner, so the browser cannot start it until the CSS has been fetched and applied. That is pure load delay, the subpart that should be under 10 percent. Preload it from the head instead.

<link rel="preload" as="image" href="/media/hero-800.avif"
      imagesrcset="/media/hero-400.avif 400w, /media/hero-800.avif 800w, /media/hero-1600.avif 1600w"
      imagesizes="100vw" fetchpriority="high">

Preload the format you most want served rather than one link per format, and let browsers without support fall through to the normal path.

Prove it from the command line

# does the edge actually negotiate, and does it say so to caches
curl -sI -H 'Accept: image/avif,image/webp,image/*;q=0.8' \
  https://example.com/media/hero.jpg | grep -i 'content-type\|vary\|cache-control'

# and the same URL for a client that cannot take AVIF
curl -sI -H 'Accept: image/png,image/*;q=0.8' \
  https://example.com/media/hero.jpg | grep -i 'content-type'

Two responses, two content types, one Vary: Accept on both. If the second request also returns AVIF, something upstream is ignoring the negotiation and a share of your visitors are getting an image they cannot render.

Most of this survives a theme upgrade precisely because it never entered the theme. If you want the delivery layer built and measured rather than guessed at, that is what our server setup and optimization work does, and monitoring and observability is how you keep the number honest once it drops. For the field data side of the same problem, see measuring real-world performance beyond Lighthouse.

Talk to the engineer who will own your stack.

No account managers, no offshore handoff. Senior DevOps, direct. Tell us what you are dealing with and you get a straight answer.