LCP is four consecutive spans of time, not one number
Until you know which of the four is eating the budget, you are guessing at which fix to apply. Four checks for a static site, each with the command that finds the problem.

On this page
LCP is usually discussed as though it were a single number you make smaller. It is not. It is four consecutive spans of time laid end to end, and until you know which of the four is eating your budget you are guessing at which fix to apply.
Largest Contentful Paint reports the render time of the largest image or text block visible in the viewport. web.dev’s LCP page gives the target: “sites should strive to have Largest Contentful Paint of 2.5 seconds or less,” and it is explicit about where to read that from: “a good threshold to measure is the 75th percentile of page loads, segmented across mobile and desktop devices.”
The percentile is the harder half of that sentence. Your 75th-percentile visitor is not your average one. Old phone, poor connection, cold cache. A comfortable median tells you very little about the number Google is actually scoring, and the gap between the two is where most people’s optimism lives.
So the useful question is never “is my LCP good” but “which of the four spans is spending the budget, on the quarter of visits I have not been looking at”. That distinction is the whole point, because a 3.4-second LCP caused by a font that blocks a headline and a 3.4-second LCP caused by an uncompressed hero on a slow connection are the same number describing two entirely different repairs.
One thing to fix before the model. Text counts. LCP candidates are <img>, <image> inside
<svg>, <video>, an element with a background image loaded via url(), and block-level
elements containing text nodes or inline text children. So if your hero is a headline in a
webfont, the font file is on the LCP path and you should treat it exactly as seriously as an
image.
The four sub-parts
web.dev’s optimization guide breaks LCP into four consecutive parts and gives a target share for each.
| Sub-part | What it is | Target share of LCP |
|---|---|---|
| Time to first byte | until the first byte of the HTML response | ~40% |
| Resource load delay | from TTFB until the LCP resource starts loading | <10% |
| Resource load duration | transferring the LCP resource | ~40% |
| Element render delay | from resource loaded until the element paints | <10% |
The two small numbers are the diagnostic ones. web.dev flags this itself: of the four sub-parts, two have the word “delay” in their names, which “is a clue that you want to get these times as close to zero as possible.” Nothing useful happens during a delay. If either delay is large you have a discovery problem or a blocking problem, and those are usually the cheap fixes. TTFB and load duration are real work, which is why they get 40% each.
For a static site behind a CDN, hosting has already largely solved TTFB. Which leaves load delay plus load duration. On most pages both of those are properties of exactly one image.
The guide adds a warning worth repeating, because the table invites the mistake: “Given the 2.5 second target for LCP, it may be tempting to try to convert these percentages into absolute numbers, but that is not recommended.”
The audit, in the order I run it
Four checks, each one a command you can run against your own repository in a few seconds. The
sample outputs below come from this site’s own history at 22b7c22d^ (the commit before the
machine-generated corpus was deleted), because that is a codebase whose lines I can quote in
full. It is private, so the file facts are mine and the reasoning from them is Google’s, linked
throughout.
Check 1: weigh everything the templates can put above the fold
git ls-tree -r --long 22b7c22d^ -- static/images/rezaali-fallback.png
# 100644 blob 5932e974… 2074882 static/images/rezaali-fallback.png
2,074,882 bytes. 1536 × 1024. Non-interlaced 8-bit RGB PNG. Its visible content is the word
LOADING.. in white on a near-black field, with the word SIGNALS under it.
A placeholder that never got replaced is an ordinary accident. What turns it structural is where the path is written down, so the second half of this check is to grep your archetypes and partials for it:
git show 22b7c22d^:archetypes/post.md | grep image:
# image: "images/rezaali-fallback.png"
Every post created from that archetype inherited the line, which made two megabytes of PNG the default cover image. A default is the one thing nobody looks at.
git grep -l 'images/rezaali-fallback.png' 22b7c22d^ -- content/post/insights \
| grep -vE '\.(it|ar|zh-cn)\.md$' | wc -l
# 229
229 English posts, each served through a template that rendered the cover as an <img>
carrying loading="lazy". On the branch that fires for a static path rather than a Hugo
page resource, it also carried no width and no height at all.
The repair. Not “compress the placeholder.” Do not have a placeholder. Ship no image, let
text be the LCP element, and pay a few kilobytes instead of two megabytes. PNG is lossless,
and lossless spends its bytes faithfully preserving photographic detail that a flat graphic
with three colours does not have, so the format was wrong for the content and the content was
wrong for the page. When a real photograph is ready, encode it as WebP or AVIF. Can I use
maintains current support tables for both, and WebP is the safer floor if you are shipping one
format with no <picture> fallback chain.
Check 2: find out whether your hero is lazy-loaded
git grep -n 'class="portrait"' 22b7c22d^ -- layouts/index.html
<img class="portrait" src="{{ "images/mo-rezaali.jpeg" | relURL }}"
alt="Portrait of Mo RezaAli" loading="lazy" />
A homepage hero portrait: 311,372 bytes, inside the first screen, carrying an instruction to
the browser not to hurry. No width, no height, no srcset, no fetchpriority.
loading="lazy" defers an image until it approaches the viewport. Correct below the fold.
Exactly wrong at the top, because browser-level lazy loading has to wait for layout before it
can decide whether to fetch, which pushes the request behind CSS. web.dev’s optimization guide
states it without hedging: “Never lazy-load your LCP image, as that will always lead to
unnecessary resource load delay, and will have a negative impact on LCP.”
Read that against the table. Lazy-loading your hero takes the sub-part with a target under 10% and makes it the largest line in the budget.
The repair. loading="lazy" on every image except the LCP candidate, which gets
fetchpriority="high" instead.
Check 3: confirm you have any responsive images at all
git grep -l srcset 22b7c22d^ -- layouts static
# (no output)
No output is the finding. Not a single responsive image anywhere on the site: a phone at 400 CSS pixels wide downloaded the same file as a 5K desktop. Since load duration is around 40% of the budget, and duration is bytes over bandwidth, serving four times the necessary bytes to the slowest connections is the most direct route there is to failing at the 75th percentile.
The repair.
<img src="/img/mo-800.webp"
srcset="/img/mo-400.webp 400w,
/img/mo-800.webp 800w,
/img/mo-1200.webp 1200w"
sizes="(max-width: 40rem) 90vw, 400px"
width="800" height="800"
alt="Mo RezaAli"
fetchpriority="high"
decoding="async">
Two details are not optional. width and height must be there so the browser can reserve
the box before the bytes land; a missing intrinsic size is a layout shift, and CLS has its own
threshold of 0.1 at the 75th percentile. And sizes has to describe your real CSS layout. If
sizes lies, the browser picks the wrong candidate and you have made the page worse than a
single fixed image would have been.
fetchpriority raises or lowers a resource’s priority within its class. web.dev’s Fetch
Priority guide covers both directions: high for the hero, low for images you know are
decorative.
When the image is not in the HTML
fetchpriority only helps if the preload scanner can see the image. A CSS background or a
JavaScript-injected hero cannot be discovered until the stylesheet or the script has been
parsed, and that wait is the definition of a large resource load delay. Preload it,
responsively, so you do not undo the srcset work:
<link rel="preload" as="image"
href="/img/mo-800.webp"
imagesrcset="/img/mo-400.webp 400w,
/img/mo-800.webp 800w,
/img/mo-1200.webp 1200w"
imagesizes="(max-width: 40rem) 90vw, 400px"
fetchpriority="high">
imagesrcset and imagesizes mirror the <img> attributes; web.dev’s guide to preloading
responsive images covers the pairing. A plain href preload sitting next to a responsive
<img> is a classic own goal. It fetches a fifth copy at the wrong size.
Where you can get it, the better answer is to put the hero in an <img> tag in the HTML and
delete the preload entirely. Preload is a workaround for resources the parser cannot find.
Check 4: read the first three lines of your head partial
git show 22b7c22d^:layouts/partials/mo/head.html | head -3
{{- $cssBust := now.Unix -}}
<link rel="stylesheet" href="{{ "css/new-face-mo.css" | relURL }}?v={{ $cssBust }}">
<link rel="stylesheet" href="{{ "css/mo-site.css" | relURL }}?v={{ $cssBust }}">
now.Unix is the current Unix time. As a cache key it has one disqualifying property: it is
not derived from the file’s contents. Two consequences follow.
Every deploy changes the query string on every stylesheet whether or not a byte of CSS has changed, so returning visitors re-download identical files forever. Cache-busting that fires unconditionally is cache-disabling with extra steps.
Worse, the value is computed while each template renders rather than once per build. Pages rendered on either side of a second boundary get different values, so one deploy can serve three distinct URLs for one physical file. Three URLs, three cache entries, and a visitor moving from page to page cannot reuse the stylesheet they fetched a moment earlier. Cache hits are not unlikely here. They are structurally impossible.
The repair is a content hash. Hugo’s resources.Fingerprint (“Cryptographically hashes
the content of the given resource.”) rewrites the filename to include the hash, defaulting to
SHA-256:
{{ with resources.Get "css/site.css" }}
{{ $css := . | minify | fingerprint "sha256" }}
<link rel="stylesheet"
href="{{ $css.RelPermalink }}"
integrity="{{ $css.Data.Integrity }}"
crossorigin="anonymous">
{{ end }}
The URL now changes if and only if the CSS changes, and every page in a build gets the same
URL because the input is the same. Because the URL is content-addressed, the file can then be
cached permanently. On Cloudflare Pages, via static/_headers:
/css/*
Cache-Control: public, max-age=31536000, immutable
immutable tells the browser not to revalidate during the freshness lifetime, which is safe
precisely because a changed file gets a different name. MDN’s Cache-Control reference
documents the directive. Do not put this on your HTML. HTML has to stay revalidatable or
nobody ever sees a new deploy.
Fonts: the text-LCP path
If your LCP element is a headline rather than an image, the webfont is on the critical path and the failure mode is invisible text.
font-display: swap renders the fallback face immediately and swaps when the webfont arrives;
MDN’s font-display reference documents the timeline values. swap buys you text that is
never invisible at the cost of a flash of fallback. That is the right trade when the text is
the LCP element, because under block the browser hides the text during its block period and
your LCP sits waiting on a font file.
The cost is layout shift. Fallback and webfont almost never share metrics. Three mitigations, ordered by how much I trust them:
- Self-host the font and
<link rel="preload" as="font" type="font/woff2" crossorigin>the specific faces used above the fold. Thecrossoriginattribute is required even same-origin. Omit it and the browser fetches the file twice. - Ship only the faces you actually use. Declaring a family you do not serve guarantees a fallback render for no benefit.
- Tune the fallback with
size-adjust,ascent-overrideanddescent-overridein a@font-faceblock for the local fallback family. This genuinely reduces shift, but the numbers have to be measured per font pair, I have not measured mine, and copying someone else’s values is worse than skipping the step.
web.dev’s font best-practices guide goes deeper on loading strategy.
What this walkthrough does not give you
A number. There is no field data here: this site has never had the traffic for a Chrome UX Report entry, and I removed its analytics, so there is no measured before-and-after on this page and I am not going to manufacture one.
What the four checks give you is a list of file facts (a byte weight, a loading attribute,
an absent srcset, a timestamp in a template), each with the command that produces it. Those
are cheap to gather and hard to argue with, which makes them a good place to start. They are
not a substitute for measurement. If you want a measured number for your own site, use CrUX
for field data and Lighthouse for lab data, and when the two disagree, believe the field.
Sources
Every source below was opened and checked on the date shown. Links open in this tab.
- Largest Contentful Paint (LCP) web.dev, Google web.dev Accessed 5 August 2026
- Optimize Largest Contentful Paint web.dev, Google web.dev Accessed 5 August 2026
- Web Vitals web.dev, Google web.dev Accessed 5 August 2026
- Defining the Core Web Vitals metrics thresholds web.dev, Google web.dev Accessed 5 August 2026
- Cumulative Layout Shift (CLS) web.dev, Google web.dev Accessed 5 August 2026
- Browser-level image lazy loading for the web web.dev, Google web.dev Accessed 5 August 2026
- Optimizing resource loading with the Fetch Priority API web.dev, Google web.dev Accessed 5 August 2026
- Preload responsive images web.dev, Google web.dev Accessed 5 August 2026
- Best practices for fonts web.dev, Google web.dev Accessed 5 August 2026
- font-display MDN Web Docs developer.mozilla.org Accessed 5 August 2026
- Cache-Control MDN Web Docs developer.mozilla.org Accessed 5 August 2026
- resources.Fingerprint Hugo Documentation gohugo.io Accessed 5 August 2026
- Headers Cloudflare Pages Docs developers.cloudflare.com Accessed 5 August 2026
- WebP image format Can I use caniuse.com Accessed 5 August 2026
- AVIF image format Can I use caniuse.com Accessed 5 August 2026