Optimizing INP and LCP in Modern SPAs: A Production Playbook for Core Web Vitals
SPAs fail Core Web Vitals in places multi-page sites never do — behind router swaps, hydration and long tasks. Here is a field-data-driven playbook for decomposing, instrumenting and fixing LCP and INP in production.
Photo by Atlantic Ambience on PexelsWhy SPAs Break the Core Web Vitals Model
Most performance advice was written for multi-page applications, where each navigation throws away the main thread and starts from scratch. An SPA inverts that. The expensive moment is rarely the initial HTML parse — it is the second, third, and tenth navigation, where a router swap mounts a component tree, fires three effects that each trigger a fetch, re-renders a 2,000-row table, and pins the main thread for 400 ms while the user is already tapping Add to cart.
That asymmetry matters because INP is measured per interaction, not per page load. A fast first paint buys you nothing if every subsequent click lands in a queue behind hydration work and analytics scripts.
The Thresholds You Are Actually Graded On
| Metric | Good | Needs improvement | Poor |
|---|---|---|---|
| LCP | ≤ 2.5 s | 2.5 – 4.0 s | > 4.0 s |
| INP | ≤ 200 ms | 200 – 500 ms | > 500 ms |
| CLS | ≤ 0.1 | 0.1 – 0.25 | > 0.25 |
These numbers are evaluated at the 75th percentile of real page loads in the Chrome UX Report (CrUX), not the median. Lab tools tell you why; only field data tells you whether. If Lighthouse reports LCP at 1.2 s while CrUX says 3.4 s, you have a long-tail problem — usually slow TTFB on a subset of routes or geographies — not a code-quality problem.
Deconstructing LCP Into Four Fixable Subparts
LCP is not a single metric; it is an arithmetic identity:
LCP = TTFB + resource load delay + resource load duration + element render delayChrome's guidance assigns each subpart a rough share of the total budget:
| Subpart | Target share | Typical SPA failure |
|---|---|---|
| Time to first byte | ~40% | Uncached SSR, no edge cache, redirect chains |
| Resource load delay | <10% | LCP image discovered late by JS, loading="lazy" above the fold |
| Resource load duration | ~40% | Oversized hero AVIF/WebP, no srcset, no preconnect |
| Element render delay | <10% | Client-only render, blocking CSS/JS, font swap |
Instrumenting LCP Attribution in Production
Ship the attribution build of web-vitals, not the minimal one. The extra bytes pay for themselves the first time you discover your LCP element is a <div> whose background image nobody preloaded.
import { onLCP, onINP, onCLS, type Metric } from 'web-vitals/attribution';
const ENDPOINT = '/api/rum/vitals';
function report(metric: Metric): void {
const payload = {
name: metric.name,
value: metric.value,
rating: metric.rating,
id: metric.id,
navigationType: metric.navigationType,
path: location.pathname,
attribution: metric.attribution,
};
const body = JSON.stringify(payload);
// sendBeacon survives unload; keepalive fetch is the fallback.
const queued =
navigator.sendBeacon?.(ENDPOINT, new Blob([body], { type: 'application/json' })) ?? false;
if (!queued) {
void fetch(ENDPOINT, { method: 'POST', body, keepalive: true }).catch(() => {});
}
}
onLCP(report, { reportAllChanges: true });
onINP(report, { reportAllChanges: true });
onCLS(report);On the server, bucket by attribution.lcpEntry.element (a CSS selector) so you can see which component wins the LCP race per route. A dashboard line reading IMG.hero-banner @ 3.1s is actionable; LCP is 3.1s is not.
Fixing Resource Load Delay: Preload Discipline
The most common LCP bug in SPAs is an above-the-fold image the browser cannot discover until the framework has parsed, hydrated and rendered. Apply both fixes together:
<!-- In <head>, before any script executes -->
<link rel="preload" as="image" href="/img/hero-1280.avif"
imagesrcset="/img/hero-640.avif 640w, /img/hero-1280.avif 1280w, /img/hero-1920.avif 1920w"
imagesizes="100vw" fetchpriority="high" crossorigin="anonymous" /><!-- In the component: never lazy-load the LCP element -->
<img src="/img/hero-1280.avif"
srcset="/img/hero-640.avif 640w, /img/hero-1280.avif 1280w"
sizes="100vw"
width="1280" height="720"
fetchpriority="high" decoding="async"
alt="Dashboard overview" />Note that loading="lazy" is deliberately absent. Adding it to a hero image routinely adds 600–1200 ms to LCP, because the browser defers the request until layout completes.
Fixing Element Render Delay
- Render the LCP element on the server. Use SSR or SSG for above-the-fold shells; a client-only hero is a guaranteed render-delay penalty.
- Inline critical CSS and forbid
@importin the critical path — each import serialises another round trip. - Eliminate hydration mismatches. React discards and re-renders mismatched subtrees; if the LCP element sits inside one, its server paint is thrown away.
- Preload the font or use `font-display: swap` so a text-based LCP element does not wait on a webfont.
Deconstructing INP: Three Phases, Three Different Fixes
INP reports the worst interaction latency for a page visit (with outlier rejection). It decomposes as:
INP = input delay + processing duration + presentation delay- Input delay — the event sat queued while the main thread was busy. Fix: shorter tasks,
scheduler.yield(), fewer third-party scripts. - Processing duration — your handler itself ran long. Fix: split work, move it to workers, avoid synchronous layout.
- Presentation delay — React committed, but the browser needed time to style, lay out and paint. Fix: smaller DOM, cheaper CSS, fewer layout invalidations.
Reading Long Animation Frames to Find the Real Blocker
The long-animation-frame (LoAF) API is the best INP diagnostic to ship to production, because it attributes blocking time to specific scripts and invokers.
interface LoAFEntry {
duration: number;
blockingDuration: number;
scripts: Array<{ sourceURL: string; duration: number; invoker: string }>;
}
if (PerformanceObserver.supportedEntryTypes.includes('long-animation-frame')) {
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries() as unknown as LoAFEntry[]) {
if (entry.blockingDuration < 50) continue;
console.warn('[LoAF]', {
frameMs: Math.round(entry.duration),
blockingMs: Math.round(entry.blockingDuration),
offenders: entry.scripts
.filter((s) => s.duration > 20)
.map((s) => `${s.invoker || 'anon'} @ ${s.sourceURL}`),
});
}
});
observer.observe({ type: 'long-animation-frame', buffered: true });
}Killing Long Tasks: Yielding Is a Strategy, Not a Hack
A long task is any main-thread task over 50 ms, and long tasks are the root cause of input delay. You cannot micro-optimise your way out of a 300 ms render loop — you have to break it up so the browser can interleave input between slices.
type SchedulerLike = { yield?: () => Promise<void> };
/** Cooperative yield: prefers scheduler.yield, falls back to a macrotask. */
export async function yieldToMain(): Promise<void> {
const scheduler = (globalThis as { scheduler?: SchedulerLike }).scheduler;
if (typeof scheduler?.yield === 'function') {
await scheduler.yield();
return;
}
await new Promise<void>((resolve) => setTimeout(resolve, 0));
}
/** Processes items in ~5 ms slices so input is serviced between them. */
export async function runInSlices<T>(
items: readonly T[],
work: (item: T, index: number) => void,
budgetMs = 5,
): Promise<void> {
let sliceStart = performance.now();
for (let i = 0; i < items.length; i++) {
work(items[i], i);
if (performance.now() - sliceStart >= budgetMs) {
await yieldToMain();
sliceStart = performance.now();
}
}
}scheduler.yield() is materially better than setTimeout(0) because it resumes at the front of the queue rather than the back: your continuation keeps its priority while pending input still gets serviced.
React: Transitions Beat Memoisation for Input Latency
When a keystroke triggers a 500-row re-render, useMemo does not help — the work still happens synchronously inside the event handler. useDeferredValue and startTransition let React render the urgent update (the input value) first and schedule the expensive one in an interruptible, lower-priority lane.
import { useDeferredValue, useState, useTransition, type ChangeEvent } from 'react';
export function ProductSearch() {
const [query, setQuery] = useState('');
const [rows, setRows] = useState<Row[]>([]);
const [isPending, startTransition] = useTransition();
const deferredQuery = useDeferredValue(query);
const isStale = query !== deferredQuery;
function handleChange(event: ChangeEvent<HTMLInputElement>): void {
const next = event.target.value;
setQuery(next); // urgent: keeps the input responsive
startTransition(() => {
setRows(searchIndex(next, deferredQuery)); // non-urgent: interruptible
});
}
return (
<section aria-busy={isPending}>
<input value={query} onChange={handleChange} type="search" aria-label="Search products" />
<ul style={{ opacity: isStale ? 0.5 : 1 }}>
{rows.map((row) => (
<li key={row.id}>{row.name}</li>
))}
</ul>
</section>
);
}Apply the same lever to router transitions: wrap route-change state updates in startTransition so the outgoing view stays interactive while the next chunk streams in.
Push Genuinely Expensive CPU Work Off the Main Thread
Sorting 100k rows, diffing large JSON payloads, markdown parsing and image decoding do not belong on the main thread.
// sort.worker.ts
type SortableRow = Record<string, string | number>;
interface SortRequest {
id: number;
rows: SortableRow[];
key: string;
dir: 'asc' | 'desc';
}
self.onmessage = (event: MessageEvent<SortRequest>) => {
const { id, rows, key, dir } = event.data;
const sign = dir === 'asc' ? 1 : -1;
const sorted = [...rows].sort((a, b) => {
const av = a[key];
const bv = b[key];
if (av === bv) return 0;
return (av > bv ? 1 : -1) * sign;
});
(self as unknown as { postMessage(msg: unknown): void }).postMessage({ id, sorted });
};Keep the worker alive across interactions. Spawning one per click reintroduces a 10–40 ms startup cost inside your INP budget.
Event Handler Patterns That Keep INP Under 200 ms
Never Do Synchronous Layout Inside a Handler
The classic layout-thrash pattern reads and writes geometry in the same loop, forcing a layout recalculation on every iteration:
// ❌ Forces layout N times — easily 100 ms+ on a large list
for (const el of document.querySelectorAll<HTMLElement>('.card')) {
el.style.height = `${el.offsetHeight * 2}px`;
}Batch all reads, then all writes:
// ✅ One read pass, one write pass
const cards = Array.from(document.querySelectorAll<HTMLElement>('.card'));
const heights = cards.map((el) => el.offsetHeight);
cards.forEach((el, i) => {
el.style.height = `${heights[i] * 2}px`;
});Virtualise Long Lists and Cap DOM Size
Lighthouse warns above ~800 DOM nodes and flags above ~1,400. React reconciliation cost scales with node count, and so does style recalculation on every commit. Windowed rendering (TanStack Virtual, react-window) turns a 2,000-row render into a 20-row render — often the difference between a 320 ms and a 40 ms interaction.
Defer Third-Party Scripts Off the Critical Path
Tag managers, chat widgets and A/B testing SDKs are the most common INP regressors in production, and they are usually not yours. Load them async, gate them behind first interaction or requestIdleCallback, or sandbox them in a worker (Partytown, next/third-parties). Audit monthly using the DevTools Performance panel's Bottom-Up view filtered to third-party origins.
SPA Routing: The Soft Navigation Blind Spot
Client-side route changes are soft navigations, and the Soft Navigations API is still experimental. Consequences:
- CrUX attributes all route performance to the initial hard navigation.
- Your first route's LCP may be excellent while
/dashboardtakes 4 s — and no field tool will tell you.
Mitigation: measure per-route yourself and attach location.pathname as a dimension.
export function measureRouteChange(path: string): () => void {
const start = performance.now();
return () => {
const duration = Math.round(performance.now() - start);
navigator.sendBeacon?.(
'/api/rum/route',
new Blob([JSON.stringify({ path, duration })], { type: 'application/json' }),
);
};
}Also prefetch the next route's JS chunk on hover or intent (<link rel="prefetch"> or your router's built-in prefetch). Removing a 200 ms chunk fetch from the critical path of a navigation is usually the highest-leverage SPA change available.
Production Checklist
LCP
- LCP element identified per route from field attribution data, not guessed
- Hero image preloaded with
fetchpriority="high"and neverloading="lazy" - TTFB < 800 ms at p75; HTML cache hit rate > 90%
- Above-the-fold shell is server-rendered; no client-only hero
- Critical CSS inlined; render-blocking JS removed or deferred
INP
- No task over 200 ms observed via LoAF in RUM
- Long loops chunked with
scheduler.yield()or moved to workers - Router transitions wrapped in
startTransition - Lists over ~100 items virtualised
- Third-party scripts audited monthly; none in the interaction path
web-vitals/attributionreporting into a queryable store with route dimension
Pitfalls That Quietly Wreck Your Scores
- Optimising lab data only. A 100 Lighthouse score alongside a p75 INP of 480 ms is a common and expensive illusion.
- `setTimeout(0)` as a universal yield. It deprioritises your continuation;
scheduler.yield()does not. - Debouncing handlers to "fix" INP. Debounce delays the work, not the interaction — the user still waits. Yield and chunk instead.
- Hydration mismatches. They discard server-rendered paint and re-run render, damaging both LCP and INP.
- Font-loading cascades. Multiple weights without
font-displaycan push a text LCP element past 4 s. - Benchmarking on a fast laptop. p75 field data on mid-tier Android is 2–4× worse. Throttle CPU at 4× minimum.
Sources & Technical References
- web.dev — Core Web Vitals: https://web.dev/articles/vitals
- web.dev — Optimize INP: https://web.dev/articles/optimize-inp
- web.dev — Optimize LCP: https://web.dev/articles/optimize-lcp
- Chrome for Developers — Long Animation Frames API: https://developer.chrome.com/docs/web-platform/long-animation-frames
- Chrome for Developers — scheduler.yield(): https://developer.chrome.com/blog/scheduler-yield
- W3C — Long Tasks API (Editor's Draft): https://w3c.github.io/longtasks/
- W3C — Event Timing API (INP definition): https://w3c.github.io/event-timing/
- W3C — Largest Contentful Paint (Editor's Draft): https://w3c.github.io/largest-contentful-paint/
- MDN — PerformanceObserver: https://developer.mozilla.org/en-US/docs/Web/API/PerformanceObserver
- MDN — content-visibility: https://developer.mozilla.org/en-US/docs/Web/CSS/content-visibility
- MDN — HTMLImageElement.fetchPriority: https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement/fetchPriority
- Google — web-vitals library (attribution build): https://github.com/GoogleChrome/web-vitals
- React — useTransition reference: https://react.dev/reference/react/useTransition
- Chrome UX Report — BigQuery public dataset: https://developer.chrome.com/docs/crux/bigquery
- RFC 9111 — HTTP Caching: https://www.rfc-editor.org/rfc/rfc9111
Discussion (0)
Continue Reading
More production-grade engineering deep dives and architectural guides

On-Device LLM Inference at 50M Mobile Sessions: llama.cpp vs MLX vs MediaPipe vs ExecuTorch
A production-grade benchmark and architecture guide for running 2B-class LLMs on phones at 50M sessions: prefill latency, thermal-throttled tokens/sec, RSS, and true cost per million tokens versus cloud. Learn when llama.cpp, MLX Swift, MediaPipe, or ExecuTorch wins—and where cloud fallback is still mandatory.

Landing Pages at 100M Views: Next.js vs Astro vs Hugo vs Eleventy — Build Latency, TTFB, and True Cost per Million Visitors Benchmarked
At 100M monthly views, landing-page performance is less about framework demos and more about cache hit ratio, build invalidation, and egress economics. This benchmark-driven guide compares Next.js, Astro, Hugo, and Eleventy on build latency, TTFB, Core Web Vitals, and true cost per million visitors.

Go Microservices at 1M RPS: go-zero vs Kratos vs go-micro vs go-kit on p99 Latency, Memory, and True Cost per Billion Requests
Most Go framework benchmarks measure a closed loop against an idle server, then publish a number that collapses under real traffic. Here is an open-loop, HdrHistogram-based comparison of go-zero, Kratos, go-micro, and go-kit at 1M requests per second — including the kernel tuning, the memory math, and the engineering amortization that decides which framework actually costs less to own.