Learn how to diagnose and fix slow LCP, laggy interactions and layout shifts in real-life projects with concrete tips, tools and examples. This guide isn’t just another dry technical manual.

Imagine you’re at a fancy dinner party:
LCP is the wait for your entrée
INP is how long the waiter takes to react when you ask for more bread
CLS is whether the table wobbles when someone bumps it.
Throughout this article I’ll show you how to tune each metric using modern techniques. Along the way you’ll learn why CSS always seems to block rendering, how React’s new features help with responsiveness and even how to trick Google into measuring an image that’s faded in.
1. Optimizing Largest Contentful Paint (LCP) for Better Core Web Vitals and SEO
The LCP metric is one of the Core Web Vitals and measures how quickly the largest visible element appears on screen. Google recommends an LCP below 2.5 seconds for a fast, user friendly and SEO friendly page. Slow LCP is often caused by large images, videos or fonts, but there are several techniques to improve it. Below are common strategies and practical examples.
Use Low‑Quality Image Placeholders (LQIP)
Loading a high‑resolution hero image can take time, causing a white flash until the image appears. One solution is to show a tiny placeholder while the high‑res image downloads.
Think of it as showing guests a movie poster while the projector warms up: they get a sense of what’s coming without staring at a blank screen.
CSS Wizardry’s research notes that for the browser to consider a placeholder as the LCP element, it must contain at least 0.05 bits per pixel (BPP) of data. They recommend aiming for 0.055 BPP to be safe (CSS Wizardry).
Example: A 600×400 hero placeholder should be at least (600 × 400 × 0.055) / 8 000 ≈ 1.65 KB. Generate a low‑resolution JPEG of that size and embed it directly in your HTML as a data URI:
<!-- Low‑resolution placeholder → at least 1.65 KB to meet 0.055 BPP -->
<img
src="data:image/jpeg;base64,/9j/4AAQSkZJRgABA..."
alt="Hero"
width="600"
height="400"
style="filter: blur(20px);" />
When the high‑res version arrives, swap the src attribute via JavaScript or CSS. Because the placeholder meets the BPP threshold and isn’t upscaled, Chrome will count it as the LCP candidate, giving you a faster LCP time.
Avoid Upscaling LQIPs and Meet the BPP Threshold
Chrome penalises upscaled placeholders by reducing their calculated area.
No, you cannot blow up a tiny thumbnail and call it “art”; Google will roll its eyes and pick another LCP candidate instead.
Always serve the placeholder at the same dimensions as the final image. Use the BPP formula to ensure you pass the 0.05 BPP threshold and avoid being disqualified as an LCP candidate.
Example: Suppose you have a 300×300 avatar. To ensure Chrome counts the placeholder, multiply 300 × 300 × 0.055 / 8 000 = 0.62 KB. Any image smaller than this will be ignored.
Preload LCP Elements (including Background Images)
If your hero image is defined in CSS (background-image), the browser may wait too long to fetch it. Preloading tells the browser to start downloading early.
It’s a bit like putting the kettle on before your partner asks for tea; by the time they sit down, the water’s already boiling.
CSS Wizardry suggests adding a <link> tag inside <head> with fetchpriority="high" (CSS Wizardry). Chrome’s Fetch Priority API helps the browser know this resource is critical.
Example:
<head>
<!-- Preload low‑res background; do not preload the high‑res version to avoid competition -->
<link rel="preload" as="image" href="/images/hero-low.jpg" fetchpriority="high">
<style>
.hero { background-image: url('/images/hero-low.jpg'); }
</style>
</head>
<!-- Later in CSS or via JavaScript, swap to hero-high.jpg when loaded -->
DebugBear showed that preloading a background image and using fetchpriority="high" improved LCP from 3.4 s to 1.7 s (DebugBear).
This simple change can halve your LCP—kind of like prepping your breakfast cereal the night before so you can hit the ground running.
For a deeper overview of how to analyze page speed test results and verify your LCP improvements, see the DebugBear guide Introduction To Page Speed Test Results on DebugBear
The “Opacity 0.1 Trick”
If you animate an image from invisible (opacity: 0) to fully visible, Chrome won’t count it as the LCP candidate because fully transparent elements are ignored. DebugBear recommends starting at opacity: 0.1 so the element is faint but still counted (DebugBear).
It’s the digital equivalent of whispering “I’m here” so Google sees it without overwhelming your users.
Once your JavaScript loads, remove the opacity to show the image.
Example:
.fade-in {
opacity: 0.1;
transition: opacity 1s ease;
}
.fade-in.loaded {
opacity: 1;
}
In your JavaScript, add the loaded class once the image is fully loaded. Google will measure the faint 0.1‑opacity image as the LCP, giving you a faster score.
Diagnose FCP–LCP Gaps
Large gaps between First Contentful Paint (FCP) and LCP often indicate an unoptimized hero image or an obtrusive cookie banner.
Think of FCP and LCP as the appetiser and main course; if there’s too long a gap, your dinner guests get cranky.
Tools like Chrome’s Performance tab can help you identify which element becomes LCP. If you see the LCP event occur well after FCP, reduce your hero size, preload it, or delay non‑essential pop‑ups.
Checklist to Ship (LCP Optimization)
- Low‑Quality Image Placeholder (LQIP):Use a low-res placeholder with at least 0.055 BPP to improve perceived page speed and LCP. Keep it the same dimensions as the final image.
- Avoid Upscaling: Do not upscale the placeholder, otherwise Chrome may ignore it as a valid LCP candidate.
- Preload Critical Images: Preload the main LCP image (or its low-res version) with fetchpriority="high" so the browser can render above the fold content faster.
- Opacity Trick: Start fade‑in animations at opacity: 0.1 so the element counts as LCP (as suggested by DebugBear).
- Measure FCP–LCP Gap: Use Chrome DevTools and page speed tools to see if your LCP is delayed and fix any blocking banners or heavy scripts.
2. Optimizing Interaction to Next Paint (INP) for Better Core Web Vitals
nteraction to Next Paint (INP) is now one of the Core Web Vitals and Google replaced First Input Delay (FID) with it in March 2024 (Cloudflare). While FID only measured the delay before event handlers executed, INP captures the entire lifecycle: input delay, processing time and presentation delay. A score below 200 ms is considered good and indicates that your site feels responsive.
It’s like measuring not just how long it takes your friend to hear you, but also how long they take to think and respond.
Here are techniques to keep interactions snappy and improve your INP score.
Prioritise UI Changes Before Heavy Work
Expensive tasks like analytics logging or image processing can block the main thread, delaying the next paint.
It’s like giving a TED Talk when someone just asked where the restroom is.
To improve INP, perform minimal UI updates immediately, then defer heavy work using setTimeout or requestIdleCallback.
Example:
button.addEventListener('click', () => {
// 1. Do the critical UI update (e.g., open the modal)
modal.classList.add('open');
// 2. Defer heavy work so it doesn't block the main thread
setTimeout(() => {
trackAnalyticsEvent('modal_opened');
// Other non‑critical tasks...
});
});
Prioritizing UI changes ensures the user sees immediate feedback, improving perceived responsiveness and actual INP.
Use React’s Concurrent Rendering (useTransition)
React 18 introduced concurrent features that allow you to mark certain updates as low‑priority. The useTransition hook schedules expensive state updates in the background so that urgent interactions (like typing or clicking) aren’t blocked.
Think of it as telling your to‑do list, “hey, cleaning the garage can wait, let’s feed the cat first.”
The DeveloperWay tutorial demonstrates wrapping a slow tab change in startTransition(() => setTab('projects')); during the transition, isPending is true, so you can show a spinner.
Example:
import { useState, useTransition } from 'react';
function Tabs() {
const [tab, setTab] = useState('home');
const [isPending, startTransition] = useTransition();
const handleClick = (newTab) => {
startTransition(() => {
setTab(newTab);
});
};
return (
<div>
<button onClick={() => handleClick('projects')}>Projects</button>
<button onClick={() => handleClick('reports')}>Reports</button>
{isPending && <span>Loading…</span>}
{/* Render tab content */}
</div>
);
}
With concurrent rendering, React yields control back to the browser during the transition, keeping INP low even when switching to a heavy component.
Real‑world case studies of React Server Components (RSC) show that reducing client‑side JavaScript can lower bundles by up to 30 % and cut LCP by ~65 % while dropping INP from ~250 ms to 175 ms.
In human terms: send fewer suitcases to the client so they can walk faster.
Be Mindful of DOM Repainting
Adding or removing classes triggers style recalculations and potentially reflows.
It’s like repainting your entire house just because you want to change one picture frame.
When you toggle a class to open a menu, update it at the closest ancestor necessary to avoid repainting large portions of the DOM. In React, avoid updating global state when a local state change suffices.
Isolate Elements with CSS Containment
Modern CSS offers the contain property to isolate subtrees from the rest of the document. MDN explains that contain: strict is shorthand for contain: size layout paint style. It tells the browser that the element’s layout and paint do not affect its siblings, reducing the amount of work when it updates.
Think of it as telling your kids to play in their room instead of rearranging the whole house.
However, you must specify contain-intrinsic-size to avoid the element collapsing to zero height.
Example:
/* Modal container is isolated */
.modal {
contain: strict;
contain-intrinsic-size: 200px 300px; /* fallback size */
}
When the modal opens or closes, the browser doesn’t need to repaint the entire page, which improves INP and overall interaction responsiveness.
Adopt React Server Components and the New React Compiler
React Server Components (RSC) move rendering from the client to the server.
It’s like moving your messy kitchen behind a curtain so guests don’t see it.
Thoughtbot notes that RSC can reduce bundle sizes by ~30 % because server components aren’t shipped to the client. Flutebyte’s case study observed a 62 % reduction in JS bundle size, LCP dropping by ~65 %, and INP decreasing from ~250 ms to ~175 ms.
React 19’s experimental compiler takes this further by automatically memoizing components. The official documentation explains that it eliminates the need for useMemo, useCallback and React.memo, optimizing re-renders and improving performance. This means you can write simpler code while getting automatic INP improvements and better Core Web Vitals for interaction.
Checklist to Ship (INP)
- Prioritize UI updates: Perform immediate UI changes first, then defer heavy tasks using setTimeout or requestIdleCallbackto keep interaction latency low.
- Mark non‑urgent updates with useTransition: Use React’s useTransition to run expensive state changes in the background as shown in the developerway.com tutorial.
- Minimize DOM reflows: Avoid repainting large DOM areas; update only the necessary ancestors so the browser does less layout and paint work.
- Isolate heavy components: Use contain: strict with contain-intrinsic-size to prevent reflow contagion as documented on MDN developer.mozilla.org.
- Reduce bundle size: Adopt React Server Components and the React compiler to automatically optimize re-renders and improve INP thoughtbot.comreact.dev.
3. Optimizing Cumulative Layout Shift (CLS) for Visual Stability and Core Web Vitals
The third core metric in the Core Web Vitals family, Cumulative Layout Shift (CLS), measures how often visible elements move unexpectedly on the screen. Google recommends keeping CLS below 0.1 for at least 75 % of page visits according to the web.dev docs. Poor CLS scores are usually caused by content that appears without dimensions, such as images, ads, or embeds, and by late arriving fonts web.dev. Here are a few practical ways to keep your layouts visually stable and improve your CLS score:
Understand the causes
Layout shifts happen when the browser has to push content around to make room for something that has just appeared. Web.dev lists common culprits:
- Images without dimensions. Without width and height attributes, the browser can’t reserve space
- Ads, embeds and iframes without dimensions. Third‑party content often loads at unpredictable size
- Dynamically injected content. Think cookie banners that slide down from the top
- Web fonts. A late‑loading font may take up more or less space than the fallback
Each of these can trigger unexpected movement of content and increase your CLS score, even if the rest of your page feels fast.
Always include width and height on images (or reserve space)
Specify width and height attributes on your <img> tags. This tells the browser how much space to reserve before the image loads and improves layout stability and CLS. The web.dev article recommends either including these attributes or using the CSS aspect-ratio property to reserve the required space. For responsive images, ensure each source in the <picture> element has the same aspect ratio or includes width and height attributes.
Example:
<!-- Reserve a 16:9 area for the image -->
<img src="/images/puppy.jpg" width="640" height="360" alt="Puppy with balloons">
Reserve space for third‑party content
When embedding ads or iframes, give them explicit dimensions or a placeholder container. For example:
.ad-container {
width: 300px;
height: 250px;
position: relative;
}
.ad-placeholder {
width: 100%;
height: 100%;
background-color: #f0f0f0;
}
Then insert the ad code inside .ad-container once it loads. This prevents layout jumps and protects your CLS score.
Use font-display strategies to reduce shifts
Late loading web fonts can cause text to reflow when the font finally arrives and create noticeable layout shifts that hurt your CLS score. Web.dev explains that the font-display descriptor controls how browsers behave while a font is loading. For maximum performance and minimal CLS:
- Use font-display: optional for body text. This delays text rendering for no more than 100 ms and avoids layout shifts, though the web font may not be used if it loads late web.dev.
- Use font-display: swap for branded headlines. Swap shows fallback text immediately and swaps in the web font as soon as it loads. Make sure the font file is delivered early to avoid jarring shifts.
Defer dynamic content or load it off‑screen
Cookie banners, chat widgets and other dynamic elements should reserve space or load outside the viewport so they do not push existing content down. Avoid injecting them at the top of the page after the main content has loaded. Use margins or fixed positioning so they don’t push content downward unexpectedly.
Checklist to Ship (CLS)
- Set dimensions or aspect ratios on images and video elements so the browser can reserve space and prevent layout shifts..
- Reserve space for ads, embeds and iframes before they load so they do not push existing content around..
- Optimize web fonts: Optimize web fonts: choose font-display: optional or swap and ensure fonts are preloaded to avoid text reflow and font related CLS issues.
- Defer dynamic content or load it outside the viewport; reserve space for banners and messages so they do not move main content.
- Monitor CLS: Use Chrome’s Performance panel or the web-vitals library to identify elements causing shifts and track your CLS score over time.
4. General Techniques and Trends
Load Resources in the Right Order: CSS → HTML → JavaScript
The browser builds the DOM and CSSOM (style tree) before it can paint pixels. CSS is render‑blocking, meaning the browser won’t render anything until it has downloaded and parsed all linked stylesheets. JavaScript is parser‑blocking unless marked async or defer, so it pauses HTML parsing.
In less nerdy terms: you can’t show up to the party until you’re dressed, and you can’t dress until you’ve stopped reading the instruction manual.
To achieve interactivity quickly:
- Place critical CSS in the <head> and minimize its size.
- Defer non‑critical scripts (<script defer src="main.js"></script>), or load them after the HTML.
- Avoid loading large JavaScript bundles before the DOM is ready.
Where to Place Google Tag Manager (GTM) and Other Scripts
Bounteous explains that Google recommends placing the GTM container snippet immediately inside the <head> so it loads quickly. The first script loads the container asynchronously, while the <noscript> fallback goes in the <body>. Although GTM itself is fast, the tags you configure can slow down the page.
Think of GTM as a Swiss‑army knife: incredibly useful, but if you unfold all the tools at once it becomes unwieldy.
Analytics Mania’s tests show that firing eight tracking tags in the head slowed the page by about 3 seconds on a fast 3G connection, while delaying them with a custom event improved performance.
Example: Load the GTM snippet high in the head, but use GTM’s built‑in triggers or a delayed custom event to fire non‑essential tags after the page has become interactive.
<head>
<!-- GTM container snippet -->
<script>
(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':new Date().getTime(),event:'gtm.js'});
var f=d.getElementsByTagName(s)[0],
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';
j.async=true;
j.src='https://www.googletagmanager.com/gtm.js?id='+i+dl;
f.parentNode.insertBefore(j,f);
})(window,document,'script','dataLayer','GTM‑XXXX');
</script>
</head>
<body>
<noscript><iframe src="https://www.googletagmanager.com/ns.html?id=GTM‑XXXX"
height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript>
<!-- Rest of page -->
</body>
To avoid tag bloat, fire heavy scripts after window.onload using setTimeout:
window.addEventListener('load', () => {
setTimeout(() => {
dataLayer.push({event: 'afterLoad'});
}, 1500);
});
This trick defers marketing tags and improves LCP and INP.
Code Splitting and Bundling
Bundlers like Webpack and Vite support tree shaking with ES modules, removing unused code. Combine this with dynamic imports (import() or React.lazy/Suspense) to split your application into chunks loaded on demand.
It’s the digital version of packing only the clothes you need for a weekend trip rather than dragging your entire wardrobe.
Also separate vendor libraries into their own bundle so they can be cached across pages.
Example: Because there’s no point lugging your whole wardrobe to a weekend trip, here’s how to pack only what you need:
// Lazy load a chart library only when needed
const LazyChart = React.lazy(() => import('./Chart'));
function Dashboard() {
const [showChart, setShowChart] = useState(false);
return (
<div>
<button onClick={() => setShowChart(true)}>Show Chart</button>
{showChart && (
<React.Suspense fallback={<div>Loading chart…</div>}>
<LazyChart />
</React.Suspense>
)}
</div>
);
}
Speculative Pre‑Loading with the Speculation Rules API
Chrome’s Speculation Rules API lets the browser prefetch or prerender pages it thinks the user will visit. Think of it as your browser’s crystal ball: if it’s pretty sure you’re about to click the “About us” link, it quietly fetches that page in the background. A Chrome developer article notes that prerendering can deliver a near‑zero LCP, reduced CLS and improved INP. Add a JSON configuration inside a <script type="speculationrules"> tag to hint which links should be prerendered.
Example: Here’s how to give your browser a sneak peek at where you’re headed next so it can roll out the red carpet in advance:
<script type="speculationrules">
{
"prerender": [
{ "source": "list", "urls": ["/products", "/about"] }
]
}
</script>
This instructs the browser to prerender /products and /about when it predicts the user might click them. When the user does click, the pages load almost instantly, boosting all CWV metrics.
Only Render What’s Visible with content‑visibility
The CSS content‑visibility property tells the browser to skip rendering off‑screen elements until they become visible.
It’s like telling your couch potatoes, “don’t worry about cleaning the basement until guests actually go down there.”
Web.dev explains that this reduces initial load time and the amount of work on the main thread web.dev. To avoid collapsed placeholders, specify contain-intrinsic-sizeweb.dev.
Example: Just as you wouldn’t vacuum the attic before visitors have even noticed it, the browser can skip rendering off‑screen elements until needed:
/* Only render cards when near the viewport */
.card-list {
content-visibility: auto;
contain-intrinsic-size: 300px;
}
When used on long lists or accordions, this property significantly improves INP because the browser spends less time painting off‑screen content.
Automatic Optimization with React Compiler (React 19)
React’s upcoming compiler analyzes your components at build time and automatically applies memoization. It’s like having a robo‑assistant who knows exactly when to rewash the dishes and when to leave them alone. The documentation states that the compiler makes useMemo, useCallback and React.memo unnecessary. It optimizes re‑renders and reduces the JavaScript you need to ship. When combined with RSC, this results in smaller bundles and improved INP—and fewer headaches for you.
Checklist to Ship (General)
- Load resources in order: Put critical CSS in <head> and defer or async non‑critical scripts.
- Use GTM responsibly: Place the snippet high in the <head>, but delay firing non‑essential tags.
- Split your code: Use tree shaking, dynamic imports and vendor splitting to reduce bundle size.
- Speculate wisely: Use the Speculation Rules API to prerender pages you think users will visit next.
- Only render visible content: Apply content-visibility with contain-intrinsic-size to defer off‑screen rendering.
- Leverage automatic optimization: Adopt React compiler features in React 19 to reduce manual memoization.
5. Methodology and Testing Tools
Run Multiple Tests and Use the Median
Network conditions vary from run to run. Tammy Everett’s Designing for Performance recommends running five WebPageTest runs and selecting the median result rather than a single run. This approach smooths out anomalies and gives a representative picture of your site’s performance.
Measure Specific Elements with the Element Timing API
Use the Element Timing API to verify that your LQIP and final images load in the expected order. Add the elementtiming attribute to your <img> tag and observe PerformanceEntry events in JavaScript. This confirms whether your placeholder counts as the LCP.
Example:
<img src="/images/hero-low.jpg" width="600" height="400" elementtiming="hero" alt="Hero" />
<script>
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.log(`${entry.name} rendered at ${entry.startTime} ms`);
}
});
observer.observe({type: 'element', buffered: true});
</script>
Script Synthetic User Journeys in WebPageTest
When testing pages with cookie banners or login steps, you can script those interactions. Simon Hearne shows a WebPageTest script that accepts a cookie banner: it sets an event name, navigates to the URL, then clicks the OneTrust accept button via clickAndWait id=onetrust-accept-btn-handler
simonhearne.com. Scripting ensures your measurements reflect the real user journey.
Test in Isolation
Browser extensions and cached data can skew your results. Always test in a private window or a fresh Chrome profile to eliminate caching and extensions.
Measure DOM Interactive
Use the Navigation Timing API to compare when the browser finishes parsing HTML versus when JavaScript starts executing. MDN notes that the domInteractive timestamp indicates when the parser has finished and the document’s readyState becomes 'interactive'developer.mozilla.org. You can log it via:
const nav = performance.getEntriesByType('navigation')[0];
console.log(`domInteractive at ${nav.domInteractive} ms`);
Export and Analyze HAR Files
HAR (HTTP Archive) files capture every request made by the browser. You can export a .har from Safari or Chrome DevTools and import it into another browser to analyze requests and waterfall charts. Use this to identify blocking resources and measure connection overhead. Tools like WebPageTest, Chrome DevTools and third‑party analyzers make HAR analysis easier.
Closing Thoughts
Optimizing Core Web Vitals is a blend of art and science. You balance image quality against file size, defer heavy JavaScript while keeping the interface responsive and leverage modern APIs like Speculation Rules and content‑visibility.
Remember that performance isn’t just about pleasing Google, it’s about respecting your users’ time (and sanity).
By turning your slow pages into rockets, you’ll create happier visitors and a healthier bottom line. Now go forth and make the web faster — one kilobyte and one witty analogy at a time!
Sneak Peek: Setting Up Chrome for Deep‑Dive Diagnostics
In the next instalment of this series we’ll roll up our sleeves and configure Chrome for deep‑dive performance analysis. Chrome’s DevTools isn’t just a pretty face; it lets you run custom scripts (called Snippets) that can help you answer questions like “what’s my domInteractive time?” or “how deep are my nested elements?”. Think of it like having a toolbox full of secret buttons that do exactly what you need.
Stay tuned! In the next blog we’ll walk through installing these snippets in Chrome DevTools and using them to diagnose performance bottlenecks with the flair of a mad scientist.
How to Improve Core Web Vitals (LCP, INP, CLS) in Modern Web Apps was originally published in ableneo tech & transformation on Medium, where people are continuing the conversation by highlighting and responding to this story.