Core Web Vitals Optimization Techniques
Hands-on techniques to improve LCP, INP, and CLS scores: image optimization, script deferral, layout stability patterns, and interaction responsiveness strategies.
What you'll learn
- ✓Optimization techniques for Largest Contentful Paint under 2.5s
- ✓Reducing Interaction to Next Paint with task splitting and scheduling
- ✓Eliminating Cumulative Layout Shift with sizing and containment
- ✓Measuring improvements with field data vs lab data
- ✓Framework-specific optimizations for React, Next.js, and Astro
Prerequisites
None — this post is self-contained.
Understanding what Core Web Vitals measure is the first step. The harder part is actually improving them. Each metric has different root causes and different fixes. This guide provides concrete optimization techniques for LCP, INP, and CLS, with code examples you can apply to your projects.
LCP: Largest Contentful Paint
LCP measures how long it takes for the largest visible element to render. The target is under 2.5 seconds. The largest element is usually a hero image, a heading, or a large text block.
Optimize the Critical Path
The browser cannot render the LCP element until it has downloaded and parsed the HTML, fetched render-blocking CSS, and downloaded the LCP resource. Shorten this chain:
<!-- Preload the LCP image so the browser discovers it early -->
<link rel="preload" as="image" href="/hero.webp" fetchpriority="high">
<!-- Inline critical CSS to avoid a blocking stylesheet request -->
<style>
.hero { width: 100%; height: auto; aspect-ratio: 16/9; }
.hero-heading { font-size: 2.5rem; font-weight: 700; }
</style>
<!-- Defer non-critical CSS -->
<link rel="preload" href="/styles.css" as="style" onload="this.rel='stylesheet'">
Image Optimization
Images are the LCP element on most pages. Optimize them aggressively:
<!-- Use modern formats with fallback -->
<picture>
<source srcset="/hero.avif" type="image/avif">
<source srcset="/hero.webp" type="image/webp">
<img
src="/hero.jpg"
alt="Product dashboard showing analytics"
width="1200"
height="675"
fetchpriority="high"
decoding="async"
/>
</picture>
Key attributes:
fetchpriority="high"tells the browser this image is more important than others.widthandheightprevent layout shift while the image loads.decoding="async"prevents the image decode from blocking the main thread.
Use responsive images to avoid sending desktop-sized images to mobile:
<img
srcset="/hero-400.webp 400w, /hero-800.webp 800w, /hero-1200.webp 1200w"
sizes="(max-width: 600px) 400px, (max-width: 1024px) 800px, 1200px"
src="/hero-1200.webp"
alt="Dashboard"
fetchpriority="high"
/>
Server Response Time
LCP cannot be fast if the server takes 800ms to respond. Reduce Time to First Byte (TTFB):
// Use stale-while-revalidate caching
app.get('/api/dashboard', async (req, res) => {
res.set('Cache-Control', 'public, max-age=60, stale-while-revalidate=300');
const data = await cache.getOrSet('dashboard', async () => {
return await db.query('SELECT * FROM metrics');
}, { ttl: 60 });
res.json(data);
});
For static sites, use a CDN with edge caching. For dynamic pages, consider Incremental Static Regeneration (ISR) or streaming server rendering.
INP: Interaction to Next Paint
INP measures the delay between a user interaction (click, tap, keypress) and the next visual update. The target is under 200 milliseconds. Poor INP usually comes from long tasks blocking the main thread.
Break Up Long Tasks
Any task over 50ms is a “long task” that can delay interactions. Split computation into smaller chunks:
// Bad: one long task that blocks the main thread
function processItems(items) {
for (const item of items) {
heavyComputation(item); // 200ms total
}
updateUI();
}
// Good: yield to the main thread between chunks
async function processItems(items) {
const CHUNK_SIZE = 10;
for (let i = 0; i < items.length; i += CHUNK_SIZE) {
const chunk = items.slice(i, i + CHUNK_SIZE);
for (const item of chunk) {
heavyComputation(item);
}
// Yield to let the browser handle pending interactions
await new Promise(resolve => setTimeout(resolve, 0));
}
updateUI();
}
For modern browsers, use scheduler.yield() when available:
async function processItems(items) {
for (const item of items) {
heavyComputation(item);
if (navigator.scheduling?.isInputPending()) {
await scheduler.yield();
}
}
updateUI();
}
Debounce and Throttle Event Handlers
Scroll, resize, and input handlers fire rapidly. Throttle them to avoid queuing long tasks:
// Throttle scroll handler to once per frame
let ticking = false;
document.addEventListener('scroll', () => {
if (!ticking) {
requestAnimationFrame(() => {
updateScrollPosition();
ticking = false;
});
ticking = true;
}
});
Reduce JavaScript Bundle Size
Less JavaScript means fewer long tasks during parse and execution. Audit your bundle:
# Analyze bundle composition
npx webpack-bundle-analyzer stats.json
# Or for Vite/Rollup projects
npx vite-bundle-visualizer
Common wins: replace moment.js with date-fns (tree-shakeable), lazy-load routes and heavy components, and use dynamic imports for features that are not needed immediately.
// Lazy load a heavy component
const Chart = lazy(() => import('./Chart'));
// Dynamic import triggered by user action
button.addEventListener('click', async () => {
const { processData } = await import('./data-processor.js');
processData(dataset);
});
CLS: Cumulative Layout Shift
CLS measures how much visible content shifts unexpectedly during the page’s lifetime. The target is under 0.1. Layout shifts are caused by elements that change size or position after they are rendered.
Reserve Space for Dynamic Content
The most common CLS source is images and ads that load without dimensions:
<!-- Bad: no dimensions, causes shift when image loads -->
<img src="/photo.jpg" alt="Team photo">
<!-- Good: explicit dimensions reserve space -->
<img src="/photo.jpg" alt="Team photo" width="800" height="600">
<!-- Also good: use aspect-ratio in CSS -->
<style>
.responsive-img {
width: 100%;
height: auto;
aspect-ratio: 4/3;
}
</style>
For dynamic content like ads, skeleton loaders, or API-driven sections, use min-height to reserve approximate space:
.ad-slot {
min-height: 250px;
background: #f0f0f0;
}
.feed-item-skeleton {
min-height: 120px;
contain: layout;
}
CSS Containment
The contain property tells the browser that an element’s internals do not affect the rest of the page, limiting the scope of layout recalculations:
.card {
contain: layout style;
}
.sidebar {
contain: layout size style;
}
contain: layout prevents the element’s children from affecting siblings. contain: size tells the browser the element’s size does not depend on its children. Use both when the element has a fixed size.
Font Loading Strategy
Web fonts cause layout shifts when the browser swaps from a fallback font to the loaded font. Control this with font-display and size-adjust:
@font-face {
font-family: 'Inter';
src: url('/fonts/inter.woff2') format('woff2');
font-display: swap;
/* Reduce shift by matching fallback metrics */
size-adjust: 107%;
ascent-override: 90%;
descent-override: 22%;
line-gap-override: 0%;
}
The size-adjust and override properties make the fallback font occupy approximately the same space as the web font, reducing the visual shift when the swap happens.
Measuring Improvements
Use the web-vitals library to measure real user metrics:
import { onLCP, onINP, onCLS } from 'web-vitals';
function sendToAnalytics(metric) {
fetch('/api/vitals', {
method: 'POST',
body: JSON.stringify({
name: metric.name,
value: metric.value,
rating: metric.rating, // "good", "needs-improvement", "poor"
page: location.pathname,
}),
keepalive: true,
});
}
onLCP(sendToAnalytics);
onINP(sendToAnalytics);
onCLS(sendToAnalytics);
Field data from real users is the ground truth. Lab tools like Lighthouse test on a simulated device and network, which may not reflect your actual user base. Use lab tools for debugging and field data for tracking progress.
Key Takeaways
LCP optimization focuses on the critical rendering path: preload the LCP resource, inline critical CSS, and serve optimized images. INP optimization focuses on the main thread: break long tasks into chunks, debounce event handlers, and reduce bundle size. CLS optimization focuses on layout stability: set explicit dimensions, reserve space for dynamic content, and control font loading. Measure with field data from real users, not just lab scores, and prioritize fixes based on which metric affects the most page views.
Related articles
- Web Web Performance: Core Web Vitals Guide
A practical guide to Core Web Vitals: LCP, INP, and CLS. How they are measured, what hurts them, and concrete fixes that move the numbers.
- Web SPA vs SSR vs SSG: Rendering Strategies Explained
Understand Single Page Apps, Server-Side Rendering, and Static Site Generation — how each works, performance tradeoffs, SEO impact, and when to use which.
- Go Go Profiling with pprof: CPU, Memory, and Goroutines
Learn how to profile Go applications using pprof to find CPU bottlenecks, memory leaks, and goroutine issues with practical examples.
- React React Performance: Memo, useMemo, and useCallback Guide
Optimize React performance with React.memo, useMemo, and useCallback. Learn when to use each, how to profile, and avoid common pitfalls.