Web Performance Optimization: Core Web Vitals Guide for 2026
Core Web Vitals directly impact Google search rankings. In 2026, Google uses three metrics to measure user experience: LCP, INP, and CLS. Here's how to improve each one.
The Three Core Web Vitals
LCP (Largest Contentful Paint)
Measures: How quickly the largest visible element loads Good: < 2.5s | **Needs Improvement**: 2.5s–4s | **Poor**: > 4s
The LCP element is usually:
- A hero image
- A large heading
- A background image
INP (Interaction to Next Paint)
Measures: Responsiveness to user interactions (replaced FID in 2024) Good: < 200ms | **Needs Improvement**: 200ms–500ms | **Poor**: > 500ms
CLS (Cumulative Layout Shift)
Measures: Visual stability — how much content unexpectedly moves Good: < 0.1 | **Needs Improvement**: 0.1–0.25 | **Poor**: > 0.25
Diagnosing Issues
// Measure Core Web Vitals in your app
import { getLCP, getINP, getCLS } from 'web-vitals';
getLCP(metric => {
console.log('LCP:', metric.value, metric.rating);
// Send to analytics
analytics.track('web_vital', { name: 'LCP', value: metric.value, rating: metric.rating });
});
getINP(metric => {
console.log('INP:', metric.value, metric.rating);
});
getCLS(metric => {
console.log('CLS:', metric.value, metric.rating);
});
Improving LCP
1. Optimize the LCP Image
<!-- ❌ No priority hint — browser doesn't know this is important -->
<img src="/hero.jpg" alt="Hero" />
<!-- ✅ fetchpriority="high" tells browser to load this first -->
<img
src="/hero.jpg"
alt="Hero"
fetchpriority="high"
loading="eager"
decoding="async"
/>
2. Use Modern Image Formats
<!-- ✅ Serve WebP/AVIF with fallback -->
<picture>
<source type="image/avif" srcset="/hero.avif" />
<source type="image/webp" srcset="/hero.webp" />
<img src="/hero.jpg" alt="Hero" fetchpriority="high" />
</picture>
# Convert images with sharp (Node.js)
const sharp = require('sharp');
await sharp('hero.jpg')
.resize(1200)
.webp({ quality: 80 })
.toFile('hero.webp');
await sharp('hero.jpg')
.resize(1200)
.avif({ quality: 65 })
.toFile('hero.avif');
3. Eliminate Render-Blocking Resources
<!-- ❌ Render-blocking CSS -->
<link rel="stylesheet" href="/styles.css" />
<!-- ✅ Preload critical CSS -->
<link rel="preload" href="/critical.css" as="style" onload="this.rel='stylesheet'" />
<!-- ✅ Inline critical CSS -->
<style>
/* Only above-the-fold styles */
body { margin: 0; font-family: sans-serif; }
.hero { min-height: 100vh; }
</style>
<link rel="stylesheet" href="/styles.css" media="print" onload="this.media='all'" />
<!-- ❌ Render-blocking JS -->
<script src="/app.js"></script>
<!-- ✅ Defer non-critical JS -->
<script src="/app.js" defer></script>
<script src="/analytics.js" async></script>
4. Preconnect to Critical Origins
<!-- Tell browser to start DNS + TCP + TLS early -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="dns-prefetch" href="https://api.example.com" />
5. Use a CDN
Without CDN (user in Tokyo, server in New York):
DNS resolution: ~150ms
TCP handshake: ~180ms
TLS: ~180ms
TTFB: ~400ms
Total: ~910ms before any content!
With CDN (edge in Tokyo):
DNS: ~5ms
TCP: ~10ms
TLS: ~10ms
TTFB: ~50ms
Total: ~75ms ← 12x faster
Improving INP
INP measures the time from user interaction to visual feedback. Poor INP = laggy, unresponsive UI.
1. Break Up Long Tasks
// ❌ Long task — blocks the main thread
function processAllItems(items) {
// This runs for 2 seconds, blocking all input
items.forEach(item => heavyProcess(item));
}
// ✅ Yield to the browser between chunks
async function processAllItemsAsync(items) {
const CHUNK_SIZE = 50;
for (let i = 0; i < items.length; i += CHUNK_SIZE) {
const chunk = items.slice(i, i + CHUNK_SIZE);
chunk.forEach(item => heavyProcess(item));
// Yield to browser — allows input events to be processed
await new Promise(resolve => setTimeout(resolve, 0));
// Or use scheduler.yield() in browsers that support it:
// await scheduler.yield();
}
}
2. Use Web Workers for Heavy Computation
// Move heavy work off the main thread
const worker = new Worker(new URL('./heavy-worker.js', import.meta.url));
button.addEventListener('click', () => {
// Main thread stays responsive
worker.postMessage({ data: largeDataset });
worker.onmessage = ({ data }) => {
updateUI(data.result); // Update UI when worker is done
};
});
3. Debounce Expensive Event Handlers
// ❌ Runs on every keystroke
input.addEventListener('input', () => {
expensiveSearch(input.value); // Freezes UI
});
// ✅ Debounce — waits 300ms after user stops typing
import { debounce } from 'lodash-es';
const handleSearch = debounce((value) => {
expensiveSearch(value);
}, 300);
input.addEventListener('input', () => handleSearch(input.value));
4. React Concurrent Features
import { useTransition, startTransition } from 'react';
function SearchResults() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [isPending, startTransition] = useTransition();
function handleSearch(e) {
const value = e.target.value;
setQuery(value); // Urgent — update input immediately
startTransition(() => {
// Non-urgent — can be interrupted by more urgent updates
setResults(heavyFilterOperation(value));
});
}
return (
<div>
<input value={query} onChange={handleSearch} />
{isPending ? <Spinner /> : <ResultsList results={results} />}
</div>
);
}
Improving CLS
CLS is caused by elements shifting after the page loads — images without dimensions, dynamically injected content, fonts swapping.
1. Always Specify Image Dimensions
<!-- ❌ No dimensions — browser doesn't reserve space -->
<img src="/photo.jpg" alt="Photo" />
<!-- ✅ Explicit dimensions -->
<img src="/photo.jpg" alt="Photo" width="800" height="600" />
<!-- ✅ Or use aspect-ratio in CSS -->
<img src="/photo.jpg" alt="Photo" style="aspect-ratio: 4/3; width: 100%;" />
2. Reserve Space for Dynamic Content
/* Reserve space for ads/embeds before they load */
.ad-slot {
min-height: 250px;
background: #f5f5f5;
}
/* Reserve space for async content */
.user-avatar-skeleton {
width: 40px;
height: 40px;
border-radius: 50%;
background: #e5e7eb;
animation: pulse 1.5s ease-in-out infinite;
}
3. Font Display Optimization
/* ❌ FOUT (Flash of Unstyled Text) causes layout shift */
@font-face {
font-family: 'MyFont';
src: url('/fonts/myfont.woff2');
/* font-display defaults to 'auto' */
}
/* ✅ Use font-display: optional or swap with size-adjust */
@font-face {
font-family: 'MyFont';
src: url('/fonts/myfont.woff2');
font-display: swap;
}
/* ✅ Best: Use size-adjust to match system font metrics */
@font-face {
font-family: 'MyFont Fallback';
src: local('Arial');
ascent-override: 90%;
descent-override: 22%;
line-gap-override: 0%;
size-adjust: 107%;
}
Code Splitting
// ✅ React lazy loading — split at route level
import { lazy, Suspense } from 'react';
import { Routes, Route } from 'react-router-dom';
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));
const Analytics = lazy(() => import('./pages/Analytics'));
function App() {
return (
<Suspense fallback={<PageSkeleton />}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
<Route path="/analytics" element={<Analytics />} />
</Routes>
</Suspense>
);
}
Performance Monitoring
// Report to analytics
import { getLCP, getINP, getCLS, getFID, getFCP, getTTFB } from 'web-vitals';
function sendToAnalytics({ name, value, rating, id }) {
// Google Analytics 4
gtag('event', name, {
value: Math.round(name === 'CLS' ? value * 1000 : value),
metric_id: id,
metric_rating: rating,
});
}
getLCP(sendToAnalytics);
getINP(sendToAnalytics);
getCLS(sendToAnalytics);
getFCP(sendToAnalytics);
getTTFB(sendToAnalytics);
Quick Wins Checklist
- Enable gzip/brotli compression on your server
- Add
fetchpriority="high"to LCP image - Set explicit
widthandheighton all images - Use WebP/AVIF image formats
- Lazy load below-the-fold images:
loading="lazy" - Add
deferto non-critical scripts - Preconnect to critical third-party origins
- Implement route-based code splitting
- Set
font-display: swapfor web fonts - Use a CDN for static assets
→ Benchmark your page performance metrics with the Benchmark Builder.