Core Web Vitals in 2026: What Changed and Why INP Is the New Bottleneck
Google replaced FID (First Input Delay) with INP (Interaction to Next Paint) as an official ranking signal in March 2024. By 2026, INP is the metric that most sites struggle with. FID only measured the first interaction — INP measures every interaction throughout the session and reports the 98th percentile. That means every click, tap, and keypress has to be fast, not just the first one.
The good news: the techniques for passing all three Core Web Vitals are well-established. The bad news: most teams are still optimizing for Lighthouse (synthetic) scores instead of real user metrics (CrUX data) — which is what Google actually uses for ranking.
The Three Metrics: 2026 Thresholds
| Metric | What It Measures | Good | Needs Improvement | Poor |
|---|---|---|---|---|
| LCP | How fast the largest visible element renders | ≤ 2.5s | 2.5–4s | > 4s |
| INP | How fast the page responds to user interactions | ≤ 200ms | 200–500ms | > 500ms |
| CLS | How much the page layout shifts unexpectedly | ≤ 0.1 | 0.1–0.25 | > 0.25 |
Optimizing LCP (Largest Contentful Paint)
LCP measures how quickly the biggest visible element (usually a hero image or heading) renders. The goal is under 2.5 seconds on real devices.
1. Preload the LCP Resource
The browser can't render your hero image until it discovers and downloads it. Preloading tells the browser to start downloading immediately:
<!-- In your <head> — tells the browser to start downloading NOW -->
<link rel="preload" as="image" href="/hero.webp" fetchpriority="high" />
<!-- For responsive images -->
<link rel="preload" as="image"
href="/hero-lg.webp"
imagesrcset="/hero-sm.webp 640w, /hero-md.webp 1024w, /hero-lg.webp 1920w"
imagesizes="100vw"
fetchpriority="high" />
2. Next.js Image Priority
<Image
src="/hero.webp"
alt="Hero banner"
fill
priority // adds preload + fetchpriority="high"
sizes="100vw"
quality={85}
/>
The priority prop in Next.js Image does three things: preloads the image, sets fetchpriority="high", and disables lazy loading. Use it on exactly ONE image per page — the LCP element.
3. Eliminate Render-Blocking Resources
- Inline critical CSS for above-the-fold content
- Defer non-critical JavaScript with
asyncordefer - Use
font-display: swaporfont-display: optionalfor web fonts - Move third-party scripts below the fold or load them after the page is interactive
4. Server Response Time
If your TTFB (Time to First Byte) is over 600ms, no amount of frontend optimization will save your LCP. Solutions:
- Use a CDN (Vercel, Cloudflare, Fastly) for static assets and edge-cached pages
- Enable Partial Prerendering in Next.js 15 for instant static shell delivery
- Optimize database queries (add indexes, reduce N+1 queries)
- Use connection pooling for serverless database access
Optimizing INP (Interaction to Next Paint)
INP is the hardest Core Web Vital to pass because it measures every user interaction across the entire page session. The 98th percentile means even rare slow interactions will hurt your score.
The Three Phases of an Interaction
- Input delay — time between user input and event handler starting (caused by main thread being busy)
- Processing time — time for the event handler to execute
- Presentation delay — time for the browser to render the visual update
All three must complete within 200ms total.
1. Break Up Long Tasks
Any task that takes >50ms on the main thread is a "long task" that blocks interactions. Break them up:
// ❌ Bad — one long task blocks the main thread for 200ms
function processLargeDataset(items) {
items.forEach(item => heavyComputation(item)); // blocks for 200ms
}
// ✅ Good — yield to the browser between chunks
async function processLargeDataset(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 => heavyComputation(item));
// Yield to the browser — allows pending interactions to be processed
await scheduler.yield();
}
}
2. Defer Non-Visual Work
// ❌ Bad — analytics blocks the click response
button.addEventListener("click", () => {
trackAnalytics("button_click"); // 50ms network call
updateUI(); // 20ms — user sees delay
});
// ✅ Good — update UI first, analytics can wait
button.addEventListener("click", () => {
updateUI(); // 20ms — user sees instant response
requestIdleCallback(() => {
trackAnalytics("button_click"); // runs when browser is idle
});
});
3. Tame Third-Party Scripts
Third-party scripts (analytics, ads, chat widgets) are the #1 cause of poor INP on most sites:
- Load them with
async deferor dynamically after page load - Move heavy third-party work to Web Workers
- Use
Partytownto run third-party scripts in a worker thread - Audit with Chrome DevTools Performance panel — look for long tasks caused by third-party origins
4. Virtualize Long Lists
Rendering 1,000 DOM nodes makes every interaction slow. Virtualize lists with @tanstack/virtual or react-window:
import { useVirtualizer } from "@tanstack/react-virtual";
function VirtualList({ items }) {
const parentRef = useRef(null);
const virtualizer = useVirtualizer({
count: items.length, // could be 10,000+
getScrollElement: () => parentRef.current,
estimateSize: () => 60, // estimated row height
});
return (
<div ref={parentRef} style={{ height: "500px", overflow: "auto" }}>
<div style={{ height: virtualizer.getTotalSize() }}>
{virtualizer.getVirtualItems().map(row => (
// Only renders ~15 visible items, not 10,000
<div key={row.key} style={{ transform: `translateY(${row.start}px)` }}>
{items[row.index].name}
</div>
))}
</div>
</div>
);
}
Optimizing CLS (Cumulative Layout Shift)
CLS measures unexpected visual movement on the page. Users hate it when they're about to click a button and it jumps because an ad loaded above it.
The Rules
- Always set explicit dimensions on images and videos:
widthandheightattributes, or CSSaspect-ratio - Reserve space for dynamic content: Ads, embeds, and lazy-loaded components need placeholder space
- Never insert content above existing content: Banners, cookie notices, and notification bars should use
position: fixedor reserved space - Use font-display: optional: Prevents font-swap layout shifts entirely (invisible text → no shift)
- Avoid
top/leftanimations: Usetransformfor animations — transforms don't cause layout
/* Reserve space for images with aspect-ratio */
.hero-image {
width: 100%;
aspect-ratio: 16 / 9;
object-fit: cover;
}
/* Font loading without layout shift */
@font-face {
font-family: "Inter";
src: url("/fonts/inter.woff2") format("woff2");
font-display: optional; /* no shift — uses fallback permanently if font is slow */
}
Measurement Strategy
Lighthouse scores are not what Google uses for ranking. Real User Metrics (RUM) from the Chrome User Experience Report (CrUX) are what matters. Measure both:
Lab Testing (Lighthouse / WebPageTest)
Good for debugging specific issues. Run during development to catch regressions before they reach real users.
Real User Monitoring (RUM)
What actually matters for rankings. Use the web-vitals library to track real user metrics:
import { onLCP, onINP, onCLS } from "web-vitals";
onLCP(metric => sendToAnalytics("LCP", metric));
onINP(metric => sendToAnalytics("INP", metric));
onCLS(metric => sendToAnalytics("CLS", metric));
function sendToAnalytics(name, metric) {
fetch("/api/analytics", {
method: "POST",
body: JSON.stringify({
name,
value: metric.value,
rating: metric.rating, // "good" | "needs-improvement" | "poor"
url: location.href,
device: navigator.userAgent,
}),
keepalive: true, // sends even if user navigates away
});
}
CrUX Dashboard
Check your real Core Web Vitals scores at PageSpeed Insights (per-URL) or the CrUX Dashboard (site-wide over time). These are the numbers Google uses for search ranking.
We audit and optimize web performance for real user impact and measurable SEO gains. Get a free performance audit →