Core Web Vitals Optimization: The Engineering Guide to LCP, INP, and CLS
Technical guide to fixing Core Web Vitals - LCP image optimization, INP long task splitting, CLS layout stabilization, and CI/CD performance budgets.
Core Web Vitals are Google’s three primary metrics for measuring user experience on the web. They became Google ranking signals in 2021 and are now measured in real user data collected via the Chrome User Experience Report. Poor Core Web Vitals directly affect your organic search rankings, and more importantly, they reflect genuine user experience problems that reduce engagement and conversion rates.
This guide covers each metric in depth: how it is measured, what causes violations, and specific engineering techniques to fix each one. It also covers setting up performance budgets in your CI/CD pipeline to prevent regressions.
What Core Web Vitals Measure
The three metrics each capture a different dimension of user experience:
Largest Contentful Paint (LCP) measures loading performance - specifically, when the largest visible element in the viewport (an image, a heading, a text block) finishes rendering. Good LCP is under 2.5 seconds. Poor is above 4 seconds.
Interaction to Next Paint (INP) measures responsiveness - the time from user interaction (click, tap, keyboard input) to when the browser next paints a frame. INP replaced FID (First Input Delay) as the official metric in March 2024. Good INP is under 200ms. Poor is above 500ms.
Cumulative Layout Shift (CLS) measures visual stability - how much the page layout unexpectedly shifts during loading. A button that jumps before you click it is a CLS problem. Good CLS is below 0.1. Poor is above 0.25.
Lab vs Field Data
There are two ways to measure Core Web Vitals:
Lab data is measured in a controlled environment using tools like Lighthouse or WebPageTest. Results are reproducible and useful for development and CI/CD. Lab data captures LCP well but measures INP only as FID (Total Blocking Time as a proxy for INP).
Field data is measured from real user browsers as they navigate your site. Collected via the Chrome User Experience Report (CrUX), Real User Monitoring (RUM), or the Performance API. Field data is noisy but represents actual user experience. Google’s ranking signals use field data.
The relationship: fix lab data metrics during development, then verify field data improves over the following weeks.
Fixing LCP
LCP is typically an image loading problem, a TTFB problem, or a render-blocking resource problem.
Identify Your LCP Element
First, find out what element is your LCP:
// Add to your page to log LCP element in the browser console
const observer = new PerformanceObserver((list) => {
const entries = list.getEntries();
const lastEntry = entries[entries.length - 1];
console.log('LCP element:', lastEntry.element);
console.log('LCP time:', lastEntry.startTime);
});
observer.observe({ entryTypes: ['largest-contentful-paint'] });
Common LCP elements: hero images, product images, large text blocks, background images set via CSS.
Optimize LCP Images
If your LCP element is an image, apply these optimizations:
Use modern formats: WebP and AVIF are 25-50% smaller than JPEG at equivalent quality. Serve them with a JPEG fallback.
<picture>
<source srcset="/images/hero.avif" type="image/avif">
<source srcset="/images/hero.webp" type="image/webp">
<img src="/images/hero.jpg" alt="Hero image" width="1200" height="600"
fetchpriority="high" loading="eager">
</picture>
Preload the LCP image: Adding a <link rel="preload"> for the LCP image tells the browser to fetch it at the highest priority before the HTML parser reaches the <img> tag.
<head>
<link rel="preload" as="image"
href="/images/hero.webp"
imagesrcset="/images/hero-400w.webp 400w, /images/hero-800w.webp 800w, /images/hero-1200w.webp 1200w"
imagesizes="(max-width: 600px) 400px, (max-width: 1200px) 800px, 1200px"
type="image/webp">
</head>
Always set explicit dimensions: Images without explicit width and height attributes cause CLS as the browser recalculates layout when the image loads. Always set both.
Use fetchpriority="high" on the LCP image: This hint tells the browser to fetch this image at higher priority than other resources.
Fix Server TTFB
If your LCP is a text block or the LCP is slow even after image optimization, Time to First Byte (TTFB) may be the culprit.
Improve TTFB:
- Enable HTTP/2 (reduces protocol overhead)
- Use a CDN to serve from an edge location closer to users
- Enable server-side rendering caching (Redis, Varnish, CDN cache-control headers)
- Profile your server-side rendering time - database queries during SSR add directly to TTFB
# nginx cache-control configuration
location ~* \.(html)$ {
add_header Cache-Control "public, max-age=0, must-revalidate";
}
location ~* \.(webp|jpg|png|svg|css|js)$ {
add_header Cache-Control "public, max-age=31536000, immutable";
}
Remove Render-Blocking Resources
CSS and synchronous JavaScript in the <head> block the browser from rendering anything until they load. Find render-blocking resources in Lighthouse (look for “Eliminate render-blocking resources”).
Defer non-critical JavaScript:
<!-- Bad: blocks rendering -->
<script src="analytics.js"></script>
<!-- Good: defers until after parse, maintains order -->
<script defer src="analytics.js"></script>
<!-- Good: loads asynchronously, executes immediately when ready -->
<script async src="analytics.js"></script>
Inline critical CSS, load the rest asynchronously:
<head>
<!-- Inline only the CSS needed for above-the-fold content -->
<style>/* critical CSS here */</style>
<!-- Load full stylesheet non-blocking -->
<link rel="preload" href="/styles/main.css" as="style" onload="this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="/styles/main.css"></noscript>
</head>
Fixing INP
INP is a JavaScript performance problem. Long tasks on the main thread block the browser from responding to user interactions. Any JavaScript that runs for more than 50ms is a “long task” and contributes to poor INP.
Identify Long Tasks
// Monitor long tasks in production
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.duration > 50) {
console.warn('Long task detected:', {
duration: entry.duration,
startTime: entry.startTime,
// Note: entry.attribution may identify the source
});
// Send to your RUM or analytics system
}
}
});
observer.observe({ entryTypes: ['longtask'] });
In Chrome DevTools, open Performance panel, record a trace while interacting with the page, and look for long red bars in the Main thread row.
Split Long Tasks with yield()
The core technique for fixing long tasks is breaking them into smaller chunks that yield control back to the browser between chunks.
Without yielding (blocks interactions):
function processLargeDataset(items) {
for (const item of items) {
expensiveOperation(item); // 500ms total - blocks everything
}
}
With yielding (responsive to interactions):
// scheduler.yield() is available in Chrome 115+
// Use this polyfill for broader support
function yieldToMain() {
if ('scheduler' in window && 'yield' in scheduler) {
return scheduler.yield();
}
return new Promise(resolve => setTimeout(resolve, 0));
}
async function processLargeDataset(items) {
let processed = 0;
for (const item of items) {
expensiveOperation(item);
processed++;
// Yield every 5 items to allow interaction handling
if (processed % 5 === 0) {
await yieldToMain();
}
}
}
Move Work to Web Workers
Heavy computation that does not need DOM access should run in a Web Worker on a separate thread:
// worker.js - runs on a background thread
self.onmessage = function(e) {
const { items } = e.data;
const results = items.map(item => expensiveComputation(item));
self.postMessage({ results });
};
// main.js - your application code
const worker = new Worker('/worker.js');
worker.onmessage = function(e) {
const { results } = e.data;
updateUI(results); // Back on main thread, but work is already done
};
// Send work to the worker
worker.postMessage({ items: largeDataset });
Optimize Event Handlers
Event handlers that run on every scroll, resize, or input event can block INP if they are slow.
// Bad: runs on every scroll event, potentially 60+ times/second
window.addEventListener('scroll', () => {
updateParallaxPosition(); // expensive DOM operation
});
// Good: debounced (waits for scroll to stop)
const debouncedUpdate = debounce(() => updateParallaxPosition(), 16);
window.addEventListener('scroll', debouncedUpdate, { passive: true });
// Good: throttled (runs at most once per animation frame)
let scrollPending = false;
window.addEventListener('scroll', () => {
if (!scrollPending) {
scrollPending = true;
requestAnimationFrame(() => {
updateParallaxPosition();
scrollPending = false;
});
}
}, { passive: true });
Fixing CLS
CLS measures unexpected layout shifts. The most common causes are: images without explicit dimensions, dynamically injected content (ads, banners, cookie notices), fonts that load and change text rendering, and animations that affect layout.
Reserve Space for Images and Embeds
The most common CLS source is images loading and pushing content down. Fix it by setting explicit width and height, which lets the browser reserve space before the image loads:
<!-- Bad: no dimensions, causes CLS when image loads -->
<img src="/product.webp" alt="Product">
<!-- Good: explicit dimensions, browser reserves space -->
<img src="/product.webp" alt="Product" width="600" height="400">
For responsive images, use the aspect-ratio CSS property combined with width: 100%:
.product-image {
width: 100%;
aspect-ratio: 3 / 2; /* Matches the 600x400 native dimensions */
object-fit: cover;
}
Handle Dynamic Content Carefully
Content injected after page load - notification banners, consent dialogs, chat widgets - causes CLS if it pushes existing content.
/* Reserve space for a banner that may or may not appear */
.banner-placeholder {
min-height: 60px; /* Reserve the space even before the banner loads */
}
/* Use fixed/sticky positioning for elements that appear after load */
.cookie-notice {
position: fixed;
bottom: 0;
left: 0;
right: 0;
/* Fixed positioning does not affect document flow, so no CLS */
}
Optimize Font Loading
Web fonts cause CLS because the browser renders text with a system font while the web font loads, then shifts layout when the web font replaces it. Use font-display: optional to eliminate this:
@font-face {
font-family: 'CustomFont';
src: url('/fonts/custom-font.woff2') format('woff2');
font-display: optional; /* Uses fallback if font not ready - no CLS */
}
Also preload critical fonts:
<link rel="preload" href="/fonts/custom-font.woff2" as="font" type="font/woff2" crossorigin>
Use font-size-adjust and careful fallback font selection to minimize the visual shift when the web font loads:
body {
font-family: 'CustomFont', system-ui;
font-size-adjust: 0.52; /* Adjust fallback font size to match custom font x-height */
}
CI/CD Performance Budgets
Preventing performance regressions is more efficient than fixing them after they have shipped. Lighthouse CI integrates Core Web Vitals measurement into your build pipeline.
Install and configure Lighthouse CI:
npm install -g @lhci/cli
Create .lighthouserc.js:
module.exports = {
ci: {
collect: {
url: ['http://localhost:3000/', 'http://localhost:3000/products'],
numberOfRuns: 3, // Average over 3 runs to reduce variability
},
assert: {
assertions: {
// LCP budget
'largest-contentful-paint': ['error', { maxNumericValue: 2500 }],
// INP budget (via Total Blocking Time as proxy)
'total-blocking-time': ['error', { maxNumericValue: 300 }],
// CLS budget
'cumulative-layout-shift': ['error', { maxNumericValue: 0.1 }],
// Additional performance budgets
'first-contentful-paint': ['warn', { maxNumericValue: 1800 }],
'speed-index': ['warn', { maxNumericValue: 3400 }],
// Resource size budgets
'uses-optimized-images': 'error',
'uses-responsive-images': 'warn',
'render-blocking-resources': 'warn',
},
},
upload: {
target: 'temporary-public-storage',
},
},
};
Add to your GitHub Actions workflow:
- name: Run Lighthouse CI
run: |
npm start & # Start your app
sleep 5 # Wait for app to be ready
lhci autorun
env:
LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }}
This setup fails your CI build if any commit causes LCP to exceed 2.5 seconds or CLS to exceed 0.1 - before the regression reaches production.
Core Web Vitals optimization is an ongoing practice, not a one-time project. Every deployment that adds new JavaScript, images, or fonts has the potential to regress performance. Our frontend performance audit provides a comprehensive Core Web Vitals assessment with a prioritized fix plan for your specific application.
Your P99 Deserves Better
Book a free 30-minute performance scope call with our engineers. We review your latency profile, identify the most impactful optimization target, and scope a sprint to fix it.
Talk to an Expert