INP by 60-65% without altering business logic.
- Preloading the exact LCP element with
fetchpriority="high" and AVIF/WebP fallbacks cuts LCP by 500-700ms.
- Explicit HTML dimensions +
size-adjust font overrides reduce CLS from 0.15 to 0.02 by eliminating layout recalculation during paint.
Core Solution
Fix 1: INP β The Architecture Problem Nobody Diagnoses
INP measures the full round trip from interaction to visual paint. The highest-impact fix is breaking synchronous tasks and yielding to the main thread, ensuring visual feedback renders before heavy computation.
async function handleFilterTap(filter) {
// Paint the selected state immediately
setSelectedFilter(filter);
await yieldToMain();
// Then do the expensive work
const filtered = applyFilters(products, filter);
await yieldToMain();
setResults(filtered);
}
function yieldToMain() {
if ('scheduler' in window && 'yield' in window.scheduler) {
return window.scheduler.yield();
}
return new Promise(resolve => setTimeout(resolve, 0));
}
In React, mark heavy renders as deferred to keep inputs responsive:
function ProductList({ products, filter }) {
const deferredProducts = React.useDeferredValue(products);
const filtered = useMemo(
() => applyFilters(deferredProducts, filter),
[deferredProducts, filter]
);
return (
<ul>
{filtered.map(product => (
<ProductCard key={product.id} product={product} />
))}
</ul>
);
}
Offload hidden long tasks (e.g., analytics serialization) to web workers:
// main.js
const analyticsWorker = new Worker('/analytics-worker.js');
function trackEvent(event) {
analyticsWorker.postMessage({
type: 'track',
payload: { event: event.name, data: event.data }
});
}
// analytics-worker.js
self.onmessage = function(e) {
if (e.data.type === 'track') {
// Heavy serialization and network calls happen here,
// off the main thread
const serialized = JSON.stringify(e.data.payload);
fetch('/api/analytics', {
method: 'POST',
body: serialized
});
}
};
Architecture Decision: INP rewards event handlers that do almost nothing synchronously. Anything expensive gets yielded, deferred, or offloaded.
Fix 2: LCP β It's Almost Always the Hero Image
LCP optimization requires identifying the exact LCP element in production and prioritizing it above all other resources.
Measure the LCP element in production:
import { onLCP } from 'web-vitals';
onLCP(metric => {
console.log('LCP element:', metric.entries.at(-1)?.element);
console.log('LCP value:', metric.value);
// Send to your analytics pipeline
sendToAnalytics(metric);
});
Preload the LCP resource in <head>:
<link rel="preload" as="image" href="/hero-banner.avif" type="image/avif">
Set explicit fetch priority:
<img
src="/hero-banner.avif"
fetchpriority="high"
alt="Hero banner"
width="1200"
height="600"
/>
Serve modern formats with responsive sizing:
<picture>
<source
type="image/avif"
srcset="/hero-400.avif 400w, /hero-800.avif 800w, /hero-1200.avif 1200w"
sizes="100vw"
/>
<source
type="image/webp"
srcset="/hero-400.webp 400w, /hero-800.webp 800w, /hero-1200.webp 1200w"
sizes="100vw"
/>
<img
src="/hero-1200.jpg"
fetchpriority="high"
alt="Hero banner"
width="1200"
height="600"
/>
</picture>
If LCP is a font, use font-display: swap and preload:
@font-face {
font-family: 'BrandDisplay';
src: url('/fonts/brand-display.woff2') format('woff2');
font-display: swap;
font-weight: 700;
}
<link rel="preload" href="/fonts/brand-display.woff2" as="font" type="font/woff2" crossorigin>
Fix 3: CLS β Three Rules That Cover Everything
CLS is resolved by reserving space explicitly and matching font metrics.
Rule 1: Explicit dimensions on every media element:
<!-- Good: browser reserves space immediately -->
<img src="/product.jpg" width="800" height="600" alt="Product photo" />
<!-- Bad: no space reserved, page jumps when image loads -->
<img src="/product.jpg" alt="Product photo" />
Rule 2: Reserve space for late-injected content:
.ad-slot {
min-height: 250px; /* Reserve space even before the ad loads */
}
.cookie-banner-container {
min-height: 64px;
}
Rule 3: Match font metrics with size-adjust:
@font-face {
font-family: 'BrandDisplay';
src: url('/fonts/brand-display.woff2') format('woff2');
font-display: swap;
font-weight: 700;
}
@font-face {
font-family: 'BrandDisplay';
src: local('Arial');
font-weight: 700;
font-display: swap;
size-adjust: 105.2%; /* Tune this to match the web font metrics */
ascent-override: 98%;
descent-override: 22%;
line-gap-override: 0%;
}
The Monitoring Setup That Makes This Stick
Ship real-user metrics and slice by route, device, and geography:
import { onINP, onLCP, onCLS } from 'web-vitals';
function sendToAnalytics(metric) {
const body = JSON.stringify({
name: metric.name,
value: metric.value,
rating: metric.rating,
delta: metric.delta,
id: metric.id,
navigationType: metric.navigationType,
url: window.location.href,
// Slice by route, device, country
route: window.location.pathname,
deviceType: /Mobi|Android/i.test(navigator.userAgent) ? 'mobile' : 'desktop',
});
if (navigator.sendBeacon) {
navigator.sendBeacon('/api/vitals', body);
} else {
fetch('/api/vitals', { body, method: 'POST', keepalive: true });
}
}
onINP(sendToAnalytics);
onLCP(sendToAnalytics);
onCLS(sendToAnalytics);
Set alerts at 80% of thresholds (INP: 160ms, LCP: 2.0s, CLS: 0.08) to catch regressions before they impact rankings.
Pitfall Guide
- Relying on Lighthouse Over Field Data: Lab scores simulate ideal conditions. Always validate against CrUX or real-user monitoring (RUM) at the 75th percentile.
- Preload Abuse: Preloading every asset competes for bandwidth and delays the actual LCP resource. Only preload the identified LCP element.
- Lazy-Loading the LCP Image:
loading="lazy" on above-the-fold images forces the browser to wait for scroll events, guaranteeing LCP regression.
- Ignoring Explicit Dimensions: CSS-only sizing doesn't reserve space before network fetch. Always use HTML
width/height or aspect-ratio to prevent CLS.
- Mismatched Font Metrics:
font-display: swap prevents FOIT but causes layout shifts if fallback fonts differ in size. Use size-adjust, ascent-override, and descent-override to lock metrics.
- Blocking Event Handlers with Sync Tasks: Analytics, sorting, or heavy state updates in click handlers block the main thread. Yield, defer, or offload to workers.
- Aggregating Metrics Without Slicing: A single INP/LCP/CLS average hides device/network-specific failures. Always slice by route, device class, and geography.
Deliverables
- π Core Web Vitals Architecture Blueprint: Visual flow mapping INP yield patterns, LCP resource prioritization chains, and CLS dimension-locking strategies for React/Vue/Next.js stacks.
- β
Pre-Deployment Vitals Checklist: 12-point validation sheet covering
scheduler.yield() integration, LCP preload verification, fetchpriority assignment, explicit media dimensions, font metric overrides, and RUM pipeline configuration.
- βοΈ Configuration Templates: Ready-to-use snippets for
web-vitals RUM instrumentation, <head> preload hints, @font-face metric override blocks, and CI/CD alert thresholds (INP 160ms / LCP 2.0s / CLS 0.08).