1. The Ticket That Started This
A haberdashery retailer emailed me a screenshot of Search Console on a Tuesday morning in March. Ninety-one percent of their mobile URLs had moved into the "Needs improvement" bucket for Largest Contentful Paint over the preceding twenty-eight days. Nothing had been deployed. No theme change, no new app, no migration. Their developer had spent two days looking and found nothing, and the working theory by the time it reached me was that Google had changed the thresholds.
Google had not changed the thresholds. What had changed was that the retailer had run a spring campaign, and the campaign had put a full-width video banner at the top of the category templates. The video element itself was not the LCP element — video does not qualify. The LCP element was the poster image behind it, which was a 1.4MB JPEG served at full resolution to every device, injected by the campaign app after hydration, and therefore invisible to the browser's preload scanner. On a mid-range Android on 4G it landed at 5.8 seconds.
The fix took forty minutes. Serve the poster as a responsive AVIF, put it in the server-rendered HTML with an explicit src, add fetchpriority="high", and remove the loading="lazy" the theme applied to everything. Mobile LCP came back at 2.1 seconds in the lab and, twenty-nine days later, at 2.4 seconds at the 75th percentile in the field.
I open with this because it is the shape of almost every Core Web Vitals problem I have been paid to fix. The metric is not mysterious. The element causing it is usually identifiable in under a minute. And the fix is usually small, specific, and boring. What makes this work hard is not the remediation — it is that most teams never identify the actual offending element, and so they optimise things that were never the problem.
This article is about the fixing. Identifying what is actually slow, and then changing it. The companion piece on measuring and defending Core Web Vitals covers the other half — how to know your numbers are real, how field data differs from lab data, and how to stop a regression before it ships. If you do not yet trust your measurements, start there and come back. Everything below assumes you can see a number and believe it.
2. What The Three Metrics Are Actually Timing
You need the mechanics, not the definitions. The definitions are on web.dev and they are one paragraph each. The mechanics are what tell you where to look.
LCP is a timestamp, not a duration. The browser watches paint operations and keeps a running record of the largest contentful element painted so far. Every time something bigger paints, LCP updates. When the user first interacts — scrolls, taps, presses a key — the record freezes. So LCP is "when did the biggest visible thing appear", measured from navigation start. Crucially, the candidate can change several times during load, and the one that sticks is often not the one you would have guessed.
INP is a percentile over a session, not a single event. The browser measures every discrete interaction — click, tap, keypress; not scroll, not hover — from the moment the input event is received to the moment the next frame is painted showing the result. At the end of the session it reports roughly the worst one, with a small allowance for high-interaction pages. That last detail matters: one bad interaction out of fifty will be your INP. You cannot average your way out of a slow filter panel.
CLS is a sum of session windows, which is the part everyone gets wrong. It is not the total of every shift on the page. The browser groups shifts into windows of at most five seconds, separated by gaps of a second with no shift, and reports the worst window. A page that shifts badly once and then settles scores worse than a page that shifts trivially forty times. And shifts within 500ms of a user interaction are excluded, which is why opening an accordion does not count against you but a banner arriving on its own does.
The thresholds, for reference, because people misquote them constantly: LCP good under 2.5 seconds, poor over 4.0. INP good under 200ms, poor over 500. CLS good under 0.1, poor over 0.25. Everything between good and poor is "needs improvement", which in practice means Google is not rewarding you and your users are noticing.
3. Identify The LCP Element Before You Touch Anything
This is the step teams skip, and skipping it is why so much performance work produces nothing.
I have watched a team spend a sprint compressing product thumbnails on a category page whose LCP element was the H1 heading, blocked by a web font. I have watched another team preload a hero image that had not been the LCP element since a redesign eight months earlier. Both teams were competent. Both were optimising a guess.
Paste this into the console on the page you care about and reload:
// The LCP element, its URL, and the timestamp — logged every time the candidate changes.
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.log(
Math.round(entry.startTime) + 'ms',
entry.element ? entry.element.tagName : '(no element)',
entry.url || '(text node)',
entry.size
);
if (entry.element) entry.element.style.outline = '4px solid magenta';
}
}).observe({ type: 'largest-contentful-paint', buffered: true });
The outline is the useful part. You will see the candidate change two or three times during load, and the magenta box will land on the final one. On a product page that is nearly always the main product image. On a category page it is a toss-up between the hero banner and the first product tile, and it changes depending on viewport height — which means your desktop answer and your mobile answer can be different elements requiring different fixes.
Do this on mobile emulation with CPU throttling, not on your desktop. On a fast machine with a fibre connection the whole page paints so quickly that the candidate sequence is compressed and you can misread which element actually sticks. Set the throttle to 4x CPU slowdown and Fast 4G, and reload three times.
The four phases, and which one you are actually in
Once you know the element, break its timeline into four parts. This decomposition is the single most useful diagnostic in performance work, and it comes straight out of Google's own analysis of LCP:
Time to First Byte — navigation start to the first byte of the HTML document. Resource load delay — TTFB to the moment the browser starts fetching the LCP resource. Resource load time — how long that fetch takes. Element render delay — download complete to actually painted.
Each phase has an entirely different set of fixes, and knowing which one dominates saves you from doing the wrong work. Here is a script that prints the split:
// Break LCP into its four phases. Run after the page has settled.
new PerformanceObserver((list) => {
const lcp = list.getEntries().at(-1);
const nav = performance.getEntriesByType('navigation')[0];
const ttfb = nav.responseStart;
// The resource entry for the LCP image, if it is an image at all.
const res = performance.getEntriesByName(lcp.url)[0];
const loadStart = res ? res.requestStart : lcp.loadTime;
const loadEnd = res ? res.responseEnd : lcp.loadTime;
console.table({
ttfb: Math.round(ttfb),
loadDelay: Math.round(loadStart - ttfb),
loadTime: Math.round(loadEnd - loadStart),
renderDelay: Math.round(lcp.startTime - loadEnd)
});
}).observe({ type: 'largest-contentful-paint', buffered: true });
In my experience the distribution across storefronts is roughly: half the time it is load delay, a quarter it is TTFB, a fifth it is load time, and render delay is rare but spectacular when it happens. Load delay dominating means the browser did not know about the image early enough. That is a discoverability problem, not a compression problem, and no amount of squeezing the JPEG will help.
4. Fixing Load Delay: Make The Image Discoverable
Load delay is the gap between the HTML arriving and the browser deciding to fetch your hero image. On a well-built page that gap is close to zero, because the preload scanner sees the <img> tag while the main parser is still busy and starts the fetch immediately. On a badly built page it can be three seconds.
There are exactly four ways to break discoverability, and I see all four regularly.
The image is set by JavaScript. A carousel that stores slide URLs in data-src and assigns src after hydration. The scanner sees nothing. The fetch cannot begin until your bundle has downloaded, parsed and executed — on a mid-range Android that is two seconds before the request is even made. This is the single most common cause of catastrophic LCP on Shopify themes, because every slider app does it.
The image is a CSS background. A background image is discovered only after the stylesheet has downloaded, parsed, and matched a selector to an element in the layout tree. That is three sequential steps. Worse, until quite recently background images were not eligible as LCP candidates at all in some engines, which produces the confusing situation where your visually largest element is not your LCP element. If the hero is a background image and it matters, make it an <img> with object-fit: cover.
The image is lazy-loaded. loading="lazy" on the LCP element is a self-inflicted wound and it is depressingly common, because themes apply it globally and nobody exempts the first image. The browser defers the fetch until layout confirms proximity to the viewport, which by definition happens after CSS. I have measured this at 400–700ms on category pages.
The page is client-side rendered. If the HTML the server sends is an empty <div id="root">, nothing is discoverable. No hint fixes this. The fix is server-side rendering, and if that is out of scope, at minimum emit the hero image markup in the initial document even if the framework replaces it.
The remediation, for the common case:
<!-- The LCP image on a PDP. Every attribute here is load-bearing. -->
<img
src="/media/catalog/product/oak-dining-table-1200.avif"
srcset="/media/catalog/product/oak-dining-table-640.avif 640w,
/media/catalog/product/oak-dining-table-960.avif 960w,
/media/catalog/product/oak-dining-table-1200.avif 1200w"
sizes="(max-width: 767px) 100vw, 640px"
width="1200" height="1500"
alt="Solid oak dining table, seats six"
fetchpriority="high"
decoding="sync" />
<!-- No loading="lazy". No data-src. Real src, in the server-rendered HTML. -->
fetchpriority="high" deserves its own paragraph because it is the highest-return single attribute on the platform. Browsers assign images a low initial priority and only promote them once layout reveals they are in the viewport. That promotion happens after CSS, which is exactly the delay you were trying to avoid. fetchpriority="high" short-circuits it. I have measured 300–500ms from that one attribute on image-heavy product pages, and it costs nothing.
The corollary is that you must also demote things. Priority is a bandwidth share, not a queue position — a high-priority request does not jump ahead of a low-priority one, it gets a fatter slice of the same pipe. If you mark six things high, you have marked nothing high. Put fetchpriority="low" on carousel slides two through six, and loading="lazy" on everything below the fold.
<!-- Slide 2 onward: explicitly deprioritised so they do not starve slide 1 -->
<img src="/media/gallery/detail-2.avif" fetchpriority="low" loading="lazy"
width="800" height="1000" alt="Table edge detail" decoding="async" />
5. Fixing Load Time: The Bytes Themselves
Once the fetch starts promptly, the remaining question is how long it takes. This is the phase where compression actually matters, and it is worth getting right, but it is rarely the dominant phase and I would not start here.
The three levers, in order of return: serve a modern format, serve the right dimensions, and serve it from somewhere close.
On format, AVIF beats WebP by roughly 20–30% at equivalent perceptual quality on photographic content, and WebP beats JPEG by 25–35%. The catch with AVIF is encode time — it is an order of magnitude slower to encode than WebP, which matters if you are transforming on the fly at the edge rather than at upload. My default is AVIF with a WebP fallback and a JPEG floor, generated asynchronously at upload and cached forever.
<picture>
<source type="image/avif"
srcset="/media/p/table-640.avif 640w, /media/p/table-1200.avif 1200w"
sizes="(max-width: 767px) 100vw, 640px" />
<source type="image/webp"
srcset="/media/p/table-640.webp 640w, /media/p/table-1200.webp 1200w"
sizes="(max-width: 767px) 100vw, 640px" />
<img src="/media/p/table-1200.jpg" width="1200" height="1500"
fetchpriority="high" alt="Solid oak dining table, seats six" />
</picture>
On dimensions, the mistake I see most is a sizes attribute that lies. If your product image is 640px wide in the desktop layout but sizes says 100vw, the browser picks the 1600px candidate on a 1600px viewport and you have quadrupled the bytes for nothing. Check sizes against the actual computed width at each breakpoint. In Chrome, hovering the currentSrc in the Elements panel tells you which candidate was chosen; if it is not the one you expected, sizes is wrong.
Here is a quick audit that catches the worst offenders on any page:
// Images served far larger than they are displayed. Ratio > 2 is waste.
[...document.images]
.map(img => ({
src: img.currentSrc.split('/').pop(),
natural: img.naturalWidth,
displayed: Math.round(img.getBoundingClientRect().width * devicePixelRatio),
ratio: +(img.naturalWidth / Math.max(1, img.getBoundingClientRect().width * devicePixelRatio)).toFixed(2)
}))
.filter(r => r.ratio > 2)
.sort((a, b) => b.ratio - a.ratio)
.forEach(r => console.log(r));
On delivery, an image served from an origin in Frankfurt to a customer in Sydney costs you 300ms before a byte of payload moves. Put images behind a CDN. This is table stakes and I will not spend more words on it, except to say that a surprising number of Magento installations serve /media/ from origin while serving /static/ from the CDN, because the CDN rule was written against the wrong path prefix.
6. Fixing Render Delay: When The Image Arrives And Nothing Happens
Render delay is the rarest phase and the most confusing when you hit it. The image downloaded at 900ms and painted at 2,400ms. What happened in between?
Usually one of three things. The main thread was busy — a long task was executing and the browser could not paint. A render-blocking stylesheet had not finished. Or the element was inside a container that was hidden, or had zero height, until some script gave it dimensions.
The main-thread case is the common one, and it is where LCP and INP problems share a root cause. If your page executes 900ms of JavaScript during load, everything queues behind it, including the paint of an image that arrived long ago. The fix is not an image fix, it is the long-task work described further down.
The render-blocking CSS case is worth a specific check. Every stylesheet in the head blocks the first paint, and therefore blocks LCP. A theme that ships one 340KB stylesheet for the whole site is blocking paint on rules that apply to the checkout while the customer is looking at a category page.
<!-- Inline what is needed to paint the first viewport -->
<style>/* critical: header, hero, first product row. ~12KB budget. */</style>
<!-- Load the rest without blocking paint -->
<link rel="stylesheet" href="/static/theme.css" media="print"
onload="this.media='all'" />
<noscript><link rel="stylesheet" href="/static/theme.css" /></noscript>
The media="print" trick works because a print stylesheet is not render-blocking; the onload flips it to all once it has arrived. It is a hack, it has been a hack for years, and it is still the most reliable way to do this without a build step. If you have a build step, extract genuine critical CSS instead — there is more on doing that properly in the critical CSS extraction guide.
One caveat I have been bitten by: inlining critical CSS makes your HTML bigger, and if your HTML is not cached, you now pay for those bytes on every navigation. On a site with good full-page caching that is fine. On a site where every product page is generated fresh, inlining 40KB of CSS into every response can cost more than it saves. Measure the whole page, not the one metric.
7. When The LCP Element Is Text
Roughly a fifth of the pages I audit have a text LCP — usually the H1 or a large promotional heading. This changes the fix entirely and people rarely notice they are in this case.
A text block cannot paint until its font is available or the browser gives up waiting. With font-display: block, which is the default behaviour for a bare @font-face, the browser hides the text for up to three seconds. Your LCP is then gated on a font file that is discovered inside a stylesheet, which is itself discovered inside the HTML — two round trips deep.
@font-face {
font-family: 'Storefront Sans';
src: url('/fonts/storefront-sans.woff2') format('woff2');
font-weight: 400;
font-display: swap; /* paint immediately in the fallback, swap when ready */
/* Subset to Latin so the file is 24KB, not 180KB */
unicode-range: U+0000-00FF, U+2000-206F, U+20A0-20BF;
}
font-display: swap fixes LCP and creates a CLS problem, which we deal with below. font-display: optional fixes both but means first-time visitors see the fallback for the whole session. I use swap plus metric overrides, which gets you most of both.
Pair that with a preload, because a font referenced inside CSS is discovered late by construction:
<link rel="preload" href="/fonts/storefront-sans.woff2" as="font"
type="font/woff2" crossorigin />
The crossorigin attribute is mandatory even for same-origin fonts, because font requests are made in anonymous CORS mode. Omit it and the preload does not match the real request, so you download the font twice and make the page slower. If a site has font preloads at all, this is the first thing I check, and it is wrong perhaps half the time.
8. INP: Find The Interaction, Not The Bundle
INP work fails the same way LCP work fails: teams optimise in general rather than finding the specific interaction that is slow.
Remember that INP reports approximately your worst interaction. If a customer clicks eight things on a product page and seven of them respond in 80ms, and the eighth — the size selector, which recalculates price and stock and re-renders the gallery — takes 640ms, your INP is 640ms. Making the seven fast ones faster does literally nothing.
So find the eighth. In the field this is what attribution data is for, and it is covered in the measurement companion. In the lab, this console snippet is enough:
// Log every interaction slower than 100ms, with its target element.
new PerformanceObserver((list) => {
for (const e of list.getEntries()) {
if (!e.interactionId || e.duration < 100) continue;
console.log(
Math.round(e.duration) + 'ms',
e.name, // pointerdown, click, keydown...
e.target ? e.target.tagName + '.' + e.target.className : '?',
'delay=' + Math.round(e.processingStart - e.startTime),
'work=' + Math.round(e.processingEnd - e.processingStart)
);
}
}).observe({ type: 'event', durationThreshold: 40, buffered: true });
Then use the site. Click everything a customer would click: add to cart, open the size dropdown, apply a filter, open the mini-cart, expand the description accordion, type in the search box. Six or seven interactions. The list you get back is your actual INP work queue, in priority order, and it is almost never what you expected.
The three parts of an interaction
Every interaction splits into input delay, processing time, and presentation delay, and the split tells you what to fix.
Input delay is the time between the user touching the screen and your handler starting. Non-zero input delay means the main thread was busy with something unrelated — usually a third-party script, a hydration pass, or an analytics flush. The fix is not in your handler at all.
Processing time is your event handlers running. This is the part people assume dominates and it usually does not, but when it does, the culprit is normally a synchronous re-render of far more of the page than needed.
Presentation delay is from your handler finishing to the frame appearing. Large values mean the resulting style recalculation, layout, and paint were expensive — a huge DOM, a CSS selector that forces the whole tree to be re-matched, or an animation on a layout-triggering property.
I keep this distinction front of mind because the wrong diagnosis wastes weeks. A team once rewrote their filter logic in a Web Worker to fix an INP of 520ms. The processing time was 40ms. The input delay was 430ms, caused by a tag manager loading four vendor scripts synchronously on first scroll. The worker rewrite changed nothing.
9. Breaking Long Tasks
A long task is any block of main-thread work over 50ms. During one, the browser cannot respond to input and cannot paint. The whole of INP remediation is, at bottom, making long tasks shorter.
The native tool for this is scheduler.yield(), available in Chrome 129 and later. It yields to the browser and returns a promise that resumes with priority over other pending tasks — which is the crucial difference from the old setTimeout(0) trick, where your continuation goes to the back of the queue behind everything else that woke up.
// Yield to the browser, with a fallback for engines without scheduler.yield.
function yieldToMain() {
if ('scheduler' in window && 'yield' in scheduler) {
return scheduler.yield();
}
// Fallback: back of the task queue, but still better than blocking.
return new Promise(resolve => setTimeout(resolve, 0));
}
// Apply a facet filter over 800 product tiles without blocking for 400ms.
async function applyFilter(products, predicate, render) {
let lastYield = performance.now();
const visible = [];
for (const product of products) {
visible.push(predicate(product) ? product : null);
// Yield roughly every 50ms rather than every N items — item cost varies.
if (performance.now() - lastYield > 50) {
await yieldToMain();
lastYield = performance.now();
}
}
render(visible.filter(Boolean));
}
Yield on elapsed time, not on item count. I have seen if (i % 100 === 0) in production code where the per-item cost varied by a factor of thirty depending on whether the product had variants, so the yields landed in completely the wrong places.
The other half of the technique is answering the user immediately and doing the work afterwards. If a customer taps a filter checkbox, the checkbox should tick within one frame. Whether the grid has finished re-rendering is a separate question.
checkbox.addEventListener('change', async (e) => {
// 1. Visual acknowledgement, synchronously, in this frame.
e.target.closest('.facet').classList.toggle('is-active', e.target.checked);
// 2. Let the browser paint that before we do anything expensive.
await yieldToMain();
// 3. Now the heavy part. INP was already recorded against step 1.
const results = await fetchFilteredProducts(currentFacets());
renderGrid(results);
});
In React 18 and later, useTransition expresses the same idea declaratively — mark the expensive state update as non-urgent and React will keep the urgent one responsive:
const [isPending, startTransition] = useTransition();
function onFacetToggle(facet) {
setCheckedFacets(prev => toggle(prev, facet)); // urgent: the checkbox
startTransition(() => {
setActiveQuery(buildQuery(facet)); // non-urgent: the 800-tile grid
});
}
This is genuinely effective on headless storefronts, where the usual INP failure is a global state mutation forcing the whole product grid to re-render synchronously. But it is not a substitute for rendering less. If your grid re-renders 800 components on every keystroke, startTransition makes that interruptible rather than cheap, and on a slow device interruptible-but-enormous is still slow.
10. The Third-Party Problem
On most storefronts I audit, more than half the main-thread time during load belongs to code the merchant did not write. Tag manager, analytics, chat widget, reviews, personalisation, consent banner, A/B testing, affiliate tracking, and two things nobody can identify.
Quantify it before you argue about it. This gives you a per-origin breakdown of main-thread cost:
// Long tasks, grouped by the script origin that caused them.
const byOrigin = {};
new PerformanceObserver((list) => {
for (const task of list.getEntries()) {
const src = task.attribution[0]?.containerSrc
|| task.attribution[0]?.containerName
|| 'unattributed';
let host = 'inline';
try { host = new URL(src).host; } catch (e) { /* inline or same-doc */ }
byOrigin[host] = (byOrigin[host] || 0) + task.duration;
}
}).observe({ type: 'longtask', buffered: true });
// After the page settles:
setTimeout(() => console.table(byOrigin), 8000);
Take that table to whoever owns the marketing stack. "The review widget costs 1.9 seconds of main-thread time on mobile" is a conversation. "The site feels slow" is not.
Three tactics, in the order I try them. First, delay loading until interaction or idle — most third-party scripts do not need to run in the first three seconds, and the pattern used on this very site defers the analytics tag until first pointer event or browser idle, whichever comes first. Second, move what you can into a Web Worker via Partytown, which genuinely works for tag managers and genuinely does not work for anything that needs synchronous DOM access. Third, and most effective, remove it. I have never audited a storefront that needed all of its tags, and the fastest script is the one that is not there.
What I would not do is async everything and call it done. async stops the script blocking the parser; it does nothing about the 400ms of execution once it arrives, which lands at an unpredictable moment and is frequently exactly when the customer is trying to tap something.
11. CLS: Reserve The Space
CLS is the most tractable of the three metrics. Every layout shift has an identifiable cause and nearly every cause has a mechanical fix. If your CLS is bad, it is because nobody has looked, not because it is hard.
Find the shifts first:
// Log each layout shift with the elements that moved.
new PerformanceObserver((list) => {
for (const shift of list.getEntries()) {
if (shift.hadRecentInput) continue; // user-initiated shifts do not count
console.log('shift', shift.value.toFixed(4), 'at', Math.round(shift.startTime) + 'ms');
for (const source of shift.sources || []) {
console.log(' moved:', source.node);
if (source.node?.style) source.node.style.outline = '3px dashed orange';
}
}
}).observe({ type: 'layout-shift', buffered: true });
The sources array is the whole game. It names the DOM nodes that actually moved. Nine times in ten the answer is immediately obvious once you can see the node.
Images and media
Every <img>, <video> and <iframe> needs intrinsic dimensions. On <img> that means width and height attributes — not CSS, attributes — because the browser uses them to compute an aspect ratio and reserve the box before any bytes arrive.
/* The attributes give the ratio; this keeps it responsive. */
.product-media img {
width: 100%;
height: auto; /* required, or the height attribute wins and distorts */
display: block;
}
/* For containers whose content arrives later and has a known shape */
.promo-slot {
aspect-ratio: 16 / 5;
contain: layout paint; /* isolate: internal changes cannot reflow the page */
}
contain: layout paint is underused. It tells the browser that the element's internal layout cannot affect anything outside it, so when the slot's content changes, the reflow is scoped to that box. On a page with a lot of independently-loading widgets this converts a page-wide shift into a local one that scores zero.
Fonts
With font-display: swap, text paints in the fallback font and then re-paints in the real one. If the two fonts have different metrics, every line box changes height and everything below moves. This is the classic "the page settled and then jumped" shift at around 800ms.
The fix is to make the fallback match the real font's metrics closely enough that the swap is invisible:
/* A fallback tuned to match Storefront Sans, so the swap does not reflow. */
@font-face {
font-family: 'Storefront Fallback';
src: local('Arial'), local('Helvetica Neue');
size-adjust: 104.2%; /* scale glyph widths to match */
ascent-override: 92.8%; /* pin the line box height */
descent-override: 23.6%;
line-gap-override: 0%;
}
body { font-family: 'Storefront Sans', 'Storefront Fallback', sans-serif; }
Getting those four numbers right by hand is tedious. The fontaine package and Next.js's built-in font handling both compute them automatically from the font file's metrics tables, and I would use one of those rather than eyeballing it. When I did eyeball it the first time, I got size-adjust about 6% wrong and turned a vertical shift into a horizontal one, which is somehow worse because it reflows line wraps.
Late-injected content
The cookie banner that pushes the page down. The free-shipping bar that appears at 1.2 seconds. The "you might also like" strip that expands when its API responds. Every one of these is a shift, and the pattern for all of them is the same: reserve the space in the initial layout, or take the element out of flow entirely.
For banners at the top of the page, my strong preference is position: fixed at the bottom of the viewport. It cannot shift anything because it is not in flow, and it is less intrusive besides. If it must be in flow at the top, render an empty placeholder of the correct height in the server-rendered HTML and let the script fill it.
/* Reserved from first paint; the script only ever fills it, never resizes it. */
#promo-bar {
min-height: 44px;
contain: layout;
}
@media (max-width: 767px) {
#promo-bar { min-height: 64px; } /* it wraps to two lines on mobile */
}
That media query matters more than it looks. I have shipped a reserved placeholder that was exactly right on desktop and 20px short on mobile, which produced a small shift on every mobile page load and a CLS of 0.11 — just over the threshold, from a single overlooked breakpoint.
Animations
Animate transform and opacity. Nothing else. Animating height, top, width or margin triggers layout on every frame, which costs main-thread time and registers as layout shift. transform and opacity are handled by the compositor and touch neither.
The awkward case is the accordion, where you genuinely want to animate height. Modern browsers can interpolate to height: auto via interpolate-size: allow-keywords, but support is thin; the portable approach is a grid-rows transition, which is still layout but scoped by containment. Either way, shifts within 500ms of the click are excluded from CLS, so an accordion opening on tap is not your problem. An accordion opening on its own at 900ms is.
12. A Diagnostic Table
| Symptom | Most likely cause | Fix |
|---|---|---|
| LCP 4s+, image fetch starts at 2s+ | Image set by JS, or lazy-loaded | Real src in HTML, remove lazy, add fetchpriority="high" |
| LCP high, TTFB > 800ms | Uncached application response | Full-page cache; see the platform guide |
| LCP element is the H1 | Font blocking text paint | font-display: swap, preload with crossorigin, subset |
| Image downloads at 900ms, paints at 2.4s | Main thread blocked, or CSS still blocking | Break long tasks; inline critical CSS |
| INP high, input delay dominates | Third-party script executing | Defer to idle or interaction; remove |
| INP high, processing dominates | Synchronous over-rendering | Yield, memoise, scope the update |
| INP high, presentation delay dominates | Huge DOM or layout-triggering CSS | Reduce node count, content-visibility, containment |
| CLS spike at ~800ms | Font swap changing metrics | Metric-overridden fallback face |
| CLS spike at ~1.5s | Injected banner or ad slot | Reserve height, or take out of flow |
| CLS only on mobile | Reserved height wrong at that breakpoint | Per-breakpoint min-height |
I keep a version of this pinned, because the temptation on every engagement is to start with the fix you enjoy rather than the one the evidence points at.
13. The DOM Size Problem Nobody Talks About
There is a failure mode that shows up in INP and CLS simultaneously and has one cause: too many DOM nodes.
A category page rendering 96 products, each with a card containing an image, a badge, a title, a price block with three states, a colour swatch row, a quick-add form and a wishlist button, is comfortably 8,000 nodes. Every style recalculation walks that tree. Every layout pass measures it. An interaction that changes one class on the body can force a recalculation across all 8,000.
Check it in one line:
console.log('nodes:', document.getElementsByTagName('*').length,
'max depth:', Math.max(...[...document.querySelectorAll('*')]
.map(el => { let d = 0; while (el.parentElement) { d++; el = el.parentElement; } return d; })));
Under 1,500 nodes is comfortable. Over 3,000 you will feel it on mid-range Android. Over 6,000 you have a structural problem that no amount of yielding will paper over.
The two useful levers are paginating rather than infinite-scrolling — 24 products per page rather than 96 — and content-visibility: auto, which lets the browser skip layout and paint for off-screen sections entirely:
.product-grid-row {
content-visibility: auto;
/* Without this the scrollbar jumps as rows are measured on scroll. */
contain-intrinsic-size: auto 420px;
}
contain-intrinsic-size is not optional. Omit it and the browser assumes zero height for skipped content, the page height collapses, and scrolling becomes an unpleasant lurching experience. The auto keyword tells it to remember the last measured size, which handles variable-height rows well.
I would be honest that content-visibility has sharp edges. It breaks in-page find for skipped content in some browsers, it interacts badly with anchor links into skipped regions, and it can confuse sticky positioning. I use it on long product grids and long editorial pages, and nowhere near checkout.
14. A Worked Example, Including The Part That Went Wrong
A UK furniture retailer, Magento 2.4.6 with a heavily customised Luma-derived theme, roughly 14,000 SKUs. Mobile field data at the 75th percentile when we started: LCP 4.6s, INP 380ms, CLS 0.19. All three failing. Sixty-two percent of sessions were mobile.
Diagnosis, day one. The LCP element on product pages was the main gallery image, being injected by the theme's Fotorama-derived slider after hydration. Load delay was 2,300ms of a 4,600ms LCP — the fetch was not starting until the bundle had run. TTFB was a respectable 340ms because Varnish was configured properly. INP was dominated by the size and finish selectors, which triggered a full re-render of the price block, the stock message, the delivery estimate and the gallery. CLS came from three sources: the font swap at 780ms, a delivery-estimate widget that expanded at about 1.4s, and a trust-badge row that loaded from a third party.
What we changed, and what each was worth. I insisted on shipping these one at a time over three weeks so we could attribute the movements. That discipline is unglamorous and it is the only reason I can give you these numbers.
Rendering the first gallery image as a real <img> in the Magento template with fetchpriority="high", and letting the slider adopt it on init rather than create it: LCP 4.6s to 2.9s. One template change. By a distance the best return of the whole project.
Converting product images to AVIF with a WebP fallback and fixing the sizes attribute, which had been claiming 100vw at all breakpoints: LCP 2.9s to 2.5s. Median image weight on a PDP dropped from 780KB to 210KB.
Scoping the variant-selector update so that changing finish updated only the price block and stock message rather than re-rendering the gallery: INP 380ms to 210ms.
Deferring the chat widget and the affiliate pixel to first interaction or idle: INP 210ms to 155ms. The chat widget alone was 340ms of main-thread execution during load.
Metric-overridden font fallback, reserved height for the delivery widget, and fixed dimensions on the trust badges: CLS 0.19 to 0.03.
What went wrong. Two things, and both are instructive.
The first: we initially added fetchpriority="high" to the gallery image and also preloaded it, on the theory that belt and braces was safer. The preload had a mismatched imagesizes attribute, so the browser fetched a 1600px candidate for the preload and a 640px candidate for the actual image. Two downloads. LCP got 180ms worse and it took us most of a day to see it, because we had shipped it alongside the AVIF conversion and the net movement was still positive. That is exactly the trap the one-change-at-a-time rule is meant to prevent, and we broke our own rule because the two changes "obviously" belonged together.
The second: the content-visibility: auto we added to category grid rows made INP worse on scroll-heavy sessions, because the browser was doing layout work for newly-revealed rows at exactly the moment the customer was tapping a product. It measured beautifully in the lab, where nobody scrolls. We kept it on the editorial pages and removed it from the grid.
Where it landed. Twenty-eight days after the last change, field data at the 75th percentile: LCP 2.4s, INP 148ms, CLS 0.03. All three green. Conversion rate on mobile moved from 1.31% to 1.44% over the following quarter, which the client's analytics team was comfortable attributing mostly to the performance work, though I would treat any single-cause attribution of a conversion number with some suspicion — they also changed their delivery messaging in the same window.
What I would do differently. I would have started with the third-party audit rather than finishing with it. It was the least technically interesting task, it required the most stakeholder conversation, and it turned out that three of the eleven tags loading on every page belonged to campaigns that had ended. Removing dead tags is free performance and I left it until week three because I wanted to do the engineering first.
15. Things That Look Like Fixes And Are Not
A short list of interventions I have watched teams make that produced no measurable improvement, so you can skip them.
Minifying an already-gzipped bundle further. Going from 240KB to 228KB of JavaScript changes nothing. The cost of JavaScript is parse and execute, not transfer, and on a mid-range Android those are roughly proportional to uncompressed size. Ship less code, do not compress the same code harder.
Preloading everything important. Priority is zero-sum. Six high-priority preloads means the browser divides your bandwidth six ways and nothing arrives early. Two preloads, maximum, and only for things the preload scanner genuinely cannot find.
Moving scripts to the footer. This was good advice in 2011. defer does it properly now, keeps execution order, and lets the preload scanner find the script early. A script at the bottom of the body is discovered late and still blocks the parser when it gets there.
Upgrading the server. If TTFB is 340ms and LCP is 4.6s, a faster box moves LCP to 4.5s. Backend spend fixes backend problems; check which phase dominates before you sign a hosting contract. That said, when TTFB genuinely is the problem — and on uncached Magento it often is — the platform-level answer is caching, which is covered in the Magento and Shopify platform guide.
Chasing the Lighthouse score. The score is a weighted composite of lab proxies, tuned to be a teaching tool. You can move it from 62 to 88 without moving a single field metric — TBT, which is heavily weighted, is a synthetic stand-in for INP that correlates only loosely with real interaction latency. Optimise the field metrics. The score follows, mostly.
16. Questions That Come Up
"We fixed everything and the field data has not moved." Field data at the 75th percentile is a twenty-eight-day rolling window. Day one after your fix, twenty-seven days of the old experience are still in the average. Expect no visible movement for a week, partial movement by two, and the real answer at four weeks. Check your own RUM instead, which updates immediately.
"Lighthouse says 96 but CrUX says we are failing." Almost always device and network. Lighthouse mobile emulation applies a fixed 4x CPU throttle, which is roughly a 2019 mid-range phone; a meaningful share of real ecommerce traffic is slower than that. It also runs with a cold cache, no extensions, and no consent banner, and it does not interact with the page at all, which is why lab tools cannot measure INP.
"Does CLS include shifts below the fold?" Yes, if they happen. CLS accumulates across the whole page lifecycle including everything you scroll to, which is why a lazy-loaded footer widget with no reserved height can wreck an otherwise stable page. Lab tools that never scroll will not catch it.
"Should I use font-display: optional?" If your brand font is genuinely load-bearing for the design, no — first-time visitors will not see it at all, which on a site whose traffic is mostly first-time is most of your audience. swap with metric overrides gets you a stable layout and the right font. Use optional for secondary faces where nobody will notice the fallback.
"Is INP worth optimising if we are already under 200ms?" Check the distribution rather than the 75th percentile. I have seen sites at 180ms overall with a 12% tail above 500ms, concentrated entirely on one interaction on one template. That tail is real customers having a bad time, and it is usually the cheapest thing left to fix.
"Our LCP element changes depending on the viewport." Common on category pages and genuinely annoying. Handle both: give the hero fetchpriority="high" and use media attributes on any preload so the right candidate is prioritised per breakpoint. Then verify on both, because the fix for one can pessimise the other.
"Do Core Web Vitals actually affect rankings?" They are a real but small signal, and they are a tiebreaker rather than a lever — no amount of speed will outrank genuinely better content. The honest business case is not ranking, it is that a 2.4-second page converts better than a 4.6-second one, and that effect is large and well documented. Sell it on conversion.
17. What I'd Actually Do First
Assume a storefront you have not seen before and a week to spend. Here is my order, and it is deliberately the order of evidence rather than the order of interest.
Open a product page and a category page on mobile emulation with 4x CPU throttling. Run the LCP element observer. Note which element it is on each template, and run the four-phase split. That is twenty minutes and it determines everything you do next.
If load delay dominates — and it usually does — fix discoverability. Real src in the server-rendered HTML, remove loading="lazy" from the LCP candidate, add fetchpriority="high", demote everything below the fold. This is the highest-return work available and it is normally a day.
Then use the site like a customer for five minutes with the interaction observer running. Write down every interaction over 200ms. Fix the worst one. Not all of them — the worst one, then re-measure, because INP is a maximum and fixing anything other than the maximum moves nothing.
Then run the layout-shift observer through a full page load without touching anything, and look at the sources. Reserve space for whatever moved. This is usually half a day and it usually takes CLS to green outright.
Then, and only then, the third-party audit. Get the per-origin long-task table, take it to the marketing team, and delete what nobody can justify. Budget more calendar time than engineering time for this one.
Two habits that make the difference between a project that holds and one that regresses in a quarter. Ship one change at a time and let each sit long enough to attribute — I broke that rule on the furniture project and it cost me a day. And put a budget in CI so the next person cannot undo the work by accident, which is the subject of the measurement and regression-detection companion to this piece.
The thing I would most like you to take away is the diagnostic discipline rather than any specific technique. Every fix in this article is public knowledge and has been for years. The reason storefronts stay slow is not that the fixes are secret; it is that teams apply them to the wrong element, on the wrong template, based on a number they measured in the wrong conditions. Find the element. Split the phases. Fix the dominant one. Then measure again and see if you were right, because about a third of the time you will not have been, and that is the useful part.
Suggested & Related Reading
Explore related engineering guides from Kenneth D'Silva:
-
Why SEO Matters for Ecommerce: The Architectural & Business Guide
Crawler architecture, dynamic rendering, and large-scale taxonomy structuring methodologies.
-
Performance Optimization for Magento & Shopify: The Engineering Blueprint
Varnish edge caching, Redis clustering architectures, and database query optimization strategies.
-
Critical CSS Optimization
Automated extraction pipelines and rendering path optimization for blocking resources on massive platforms.