Optimizing INP and LCP in Modern SPAs: A Production Playbook
INP and LCP fail in single page applications for architectural reasons, not cosmetic ones. This playbook shows how to attribute interaction latency by phase, hunt long animation frames, and restructure the render waterfall so client-rendered routes actually hit the thresholds.
Photo by Jakub Zerdzicki on PexelsInteraction to Next Paint (INP) replaced First Input Delay as a Core Web Vital in March 2024, and it is the metric that most reliably exposes architectural debt in single page applications. LCP, meanwhile, punishes the exact pattern SPAs are built on: an almost-empty HTML shell that only produces a largest element after JavaScript has parsed, hydrated, fetched, and reconciled.
The thresholds are unforgiving at p75 of field data: LCP ≤ 2.5s, INP ≤ 200ms, CLS ≤ 0.1. This article is a working playbook for engineers who already know the definitions and need to move the numbers without a rewrite.
Why SPAs Concentrate INP and LCP Risk
Three structural properties make SPAs harder than multi-page apps:
- The main thread is the product. Routing, data fetching, state derivation, and reconciliation all compete with input handling on the same thread that must respond within ~200ms.
- The LCP element is manufactured, not shipped. In a client-rendered route, the largest element often cannot exist until a chunk loads, a fetch resolves, and a component tree renders.
- Navigation is invisible to the browser. Client-side route changes don't reset the navigation timeline, so route-level LCP regressions hide inside a single page view unless you instrument soft navigations explicitly.
The LCP budget is four numbers, not one
Every LCP score decomposes into: TTFB → resource load delay → resource load duration → element render delay. If you optimize the hero image while TTFB is 1.4s, you are tuning the wrong quarter of the budget.
Instrumentation: Measure INP by Phase, Not by Gut Feeling
INP is not an average. It is approximately the p98 of observed interactions (one high interaction per ~50, discarding outliers), which means a single janky dropdown can define your score. The attribution build of the web-vitals library gives you the phase split you need to act on.
Capturing INP with phase attribution
// rum/vitals.ts
import { onINP, onLCP, type Metric } from 'web-vitals/attribution';
function report(metric: Metric): void {
const nav = performance.getEntriesByType('navigation')[0] as
| PerformanceNavigationTiming
| undefined;
const payload = JSON.stringify({
id: metric.id,
name: metric.name,
value: metric.value,
rating: metric.rating,
navigationType: nav?.type ?? 'unknown',
// INP: inputDelay + processingDuration + presentationDelay
// LCP: timeToFirstByte + resourceLoadDelay + resourceLoadDuration + elementRenderDelay
attribution: metric.attribution,
});
// sendBeacon survives unload; fall back to fetch keepalive where unavailable.
if (!navigator.sendBeacon('/rum/vitals', payload)) {
void fetch('/rum/vitals', { method: 'POST', body: payload, keepalive: true });
}
}
onINP(report, { reportAllChanges: false });
onLCP(report, { reportAllChanges: false });The INP attribution object exposes inputDelay, processingDuration, presentationDuration, target (a CSS selector), and longAnimationFrameEntries. Alert on the largest phase contribution, not on the composite number — that tells you whether to fix scheduling, handler cost, or rendering.
Decomposing LCP subparts from raw performance entries
type LcpEntry = PerformanceEntry & {
renderTime: number;
loadTime: number;
size: number;
url?: string;
};
const nav = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming;
const observer = new PerformanceObserver((list) => {
const entry = list.getEntries().at(-1) as LcpEntry | undefined;
if (!entry || !nav) return;
const subparts = {
ttfb: Math.round(nav.responseStart),
resourceLoadDelay: Math.round(Math.max(0, entry.startTime - nav.responseEnd)),
resourceLoadDuration: Math.round(Math.max(0, entry.loadTime - entry.startTime)),
elementRenderDelay: Math.round(Math.max(0, entry.renderTime - entry.loadTime)),
total: Math.round(entry.startTime + (entry.renderTime - entry.startTime)),
};
navigator.sendBeacon('/rum/lcp', JSON.stringify({ subparts, url: entry.url }));
});
observer.observe({ type: 'largest-contentful-paint', buffered: true });
// LCP candidates keep arriving; finalize on first interaction or page hide.
['keydown', 'click', 'visibilitychange'].forEach((event) =>
addEventListener(event, () => observer.disconnect(), { once: true })
);A resourceLoadDelay above ~300ms almost always means the LCP image was discovered late (lazy-loaded, injected by JS, or hidden behind a CSS background that the preload scanner can't see).
Long Animation Frames: Finding the 200ms You Cannot See in a Flame Chart
The Long Tasks API tells you that the main thread stalled. The Long Animation Frames (LoAF) API tells you which script, function, and invoker stalled it, plus how much of the frame was spent in style and layout.
new PerformanceObserver((list) => {
for (const frame of list.getEntries() as PerformanceLongAnimationFrameTiming[]) {
if (frame.duration < 50) continue;
const worstScript = [...frame.scripts].sort((a, b) => b.duration - a.duration)[0];
console.log(`LoAF ${Math.round(frame.duration)}ms`, {
blockingDuration: Math.round(frame.blockingDuration),
styleAndLayout: Math.round(
(frame.styleAndLayoutStart > 0 ? frame.styleAndLayoutStart : frame.startTime) -
frame.renderStart
),
topScript: worstScript
? {
source: worstScript.sourceURL,
fn: worstScript.sourceFunctionName,
invoker: worstScript.invoker,
duration: Math.round(worstScript.duration),
forcedStyleAndLayout: Math.round(worstScript.forcedStyleAndLayoutDuration),
}
: null,
});
}
}).observe({ type: 'long-animation-frame', buffered: true });A high forcedStyleAndLayoutDuration is a smoking gun for layout thrashing — interleaved reads and writes inside a loop. A high duration with a named sourceFunctionName points at an unmemoized selector or a synchronous JSON transform in a render path.
Phase 1 — Shrink Input Delay by Yielding Deliberately
Input delay is the gap between the user's tap and your handler running. It is caused by work already on the main thread. The fix is cooperative scheduling: keep every contiguous task under ~50ms and explicitly hand control back between chunks.
// lib/scheduler.ts
export interface SchedulerLike {
yield?: () => Promise<void>;
postTask?: (cb: () => void, opts?: { priority?: 'user-blocking' | 'user-visible' | 'background' }) => Promise<unknown>;
}
const scheduler = (globalThis as { scheduler?: SchedulerLike }).scheduler;
/** Prefer scheduler.yield (priority inheritance), then postTask, then a macrotask. */
export function yieldToMain(): Promise<void> {
if (scheduler?.yield) return scheduler.yield();
if (scheduler?.postTask) {
return scheduler.postTask(() => {}, { priority: 'user-visible' }) as Promise<void>;
}
return new Promise<void>((resolve) => setTimeout(resolve, 0));
}
/** Process a list in time-boxed chunks so input can interleave. */
export async function chunked<T>(
items: readonly T[],
work: (item: T, index: number) => void,
budgetMs = 5
): Promise<void> {
let deadline = performance.now() + budgetMs;
for (let i = 0; i < items.length; i++) {
work(items[i], i);
if (performance.now() >= deadline) {
await yieldToMain();
deadline = performance.now() + budgetMs;
}
}
}setTimeout(0) is the weakest fallback: after five nested timers browsers clamp to 4ms, and it loses priority inheritance, so your continuation competes with everything else. scheduler.yield() resumes at the same priority, which is why it is the correct primitive for continuation-style yielding.
Pitfall: over-yielding. Each yield adds a scheduling round trip. Chunk for work above ~5ms, not for every DOM write.
Phase 2 — Bound Processing Time with Framework Primitives
Processing duration is the handler itself: state derivation, list filtering, serialization, DOM writes. In React 18+, concurrent features let you mark expensive updates as interruptible instead of blocking the input.
import { useDeferredValue, useMemo, useState } from 'react';
export function ProductSearch({ products }: { products: Product[] }) {
const [query, setQuery] = useState('');
// Urgent: the input value. Non-urgent: the derived render.
const deferredQuery = useDeferredValue(query);
const results = useMemo(() => {
const q = deferredQuery.trim().toLowerCase();
return q ? products.filter((p) => p.name.toLowerCase().includes(q)) : products;
}, [deferredQuery, products]);
return (
<section aria-busy={query !== deferredQuery}>
<input
value={query}
onChange={(e) => setQuery(e.currentTarget.value)}
placeholder="Search products"
/>
<ul>{results.map((p) => <li key={p.id}>{p.name}</li>)}</ul>
</section>
);
}For routing and tab switches where you need control over the transition boundary, use useTransition and render a pending state rather than blocking. For long lists, virtualization (windowing) beats any amount of memoization: reducing node count reduces layout, style, and paint cost simultaneously.
Kill layout thrashing before it reaches production
// BAD: each read forces a synchronous layout after the previous write.
for (const el of items) el.style.width = `${el.offsetWidth + 10}px`;
// GOOD: one layout read pass, then one write pass.
const widths = items.map((el) => el.offsetWidth);
items.forEach((el, i) => { el.style.width = `${widths[i] + 10}px`; });Also cap DOM size. As a working heuristic, keep interactive views under ~1,500 nodes and ~32 nesting levels; beyond that, style recalculation costs show up directly in presentation delay.
Phase 3 — Reduce Presentation Delay
Presentation delay covers style, layout, paint, and composite after your handler finishes. Common SPA culprits: animating box-shadow or filter on large subtrees, backdrop-filter on full-screen overlays, and re-rendering a route shell that invalidates styles for the entire document.
Use containment and off-screen skipping:
.card-list > li {
content-visibility: auto;
contain-intrinsic-size: auto 240px; /* prevents scrollbar jump */
}
.overlay {
contain: layout paint; /* isolate invalidation to the overlay subtree */
}Reserve will-change for elements actively animating (and remove it after), and prefer transform/opacity animations that stay on the compositor.
LCP in Client-Rendered Routes: Fix the Waterfall, Not the Image
Ship the hero in the initial HTML
If the hero is discovered by JavaScript, you have already lost the resource load delay budget. Declare it in the document head and in the markup, with explicit dimensions:
<link rel="preload" as="image" href="/hero-1200.avif"
imagesrcset="/hero-600.avif 600w, /hero-1200.avif 1200w"
imagesizes="(max-width: 640px) 100vw, 1200px"
fetchpriority="high" />
<img src="/hero-1200.avif"
srcset="/hero-600.avif 600w, /hero-1200.avif 1200w"
sizes="(max-width: 640px) 100vw, 1200px"
alt="Dashboard overview" width="1200" height="630"
fetchpriority="high" decoding="async" />Never put loading="lazy" on an above-the-fold LCP candidate, and avoid CSS background-image for hero art unless you preload it — the preload scanner cannot see it.
Don't block paint on hydration
Server-render or stream the route shell, extract critical CSS at build time, and avoid blanket React.lazy() on above-the-fold components. Hydrate islands independently so a heavy chat widget cannot delay the hero's interactivity, and use HTTP 103 Early Hints (RFC 8297) to push critical asset hints before the HTML body is generated.
Measure and pre-render soft navigations
Client-side route changes are now measurable via the Soft Navigations API, which gives each SPA route its own LCP entry. Pair that with Speculation Rules so the next route is prerendered before the click lands:
<script type="speculationrules">
{
"prerender": [
{ "where": { "href_matches": "/products/*" }, "eagerness": "moderate" }
]
}
</script>Guardrails: Budgets, CI, and RUM Alerting
Field data is the source of truth, but regressions should be caught in CI before they ship. Run Lighthouse CI or a Playwright + CDP trace job against production-like builds, assert on p75 RUM values in your dashboard, and wire alerts to the phase split rather than the composite score.
// Example budget gate consumed by a synthetic test harness.
const BUDGETS = { LCP: 2500, INP: 200, CLS: 0.1 } as const;
export function assertBudgets(samples: { lcp: number[]; inp: number[]; cls: number[] }): void {
const p75 = (values: number[]): number => {
if (values.length === 0) return 0;
const sorted = [...values].sort((a, b) => a - b);
return sorted[Math.min(sorted.length - 1, Math.ceil(0.75 * sorted.length) - 1)];
};
const failures: string[] = [];
if (p75(samples.lcp) > BUDGETS.LCP) failures.push(`LCP p75 ${p75(samples.lcp)}ms`);
if (p75(samples.inp) > BUDGETS.INP) failures.push(`INP p75 ${p75(samples.inp)}ms`);
if (p75(samples.cls) > BUDGETS.CLS) failures.push(`CLS p75 ${p75(samples.cls)}`);
if (failures.length > 0) {
throw new Error(`Performance budget exceeded: ${failures.join(', ')}`);
}
}Production Checklist
- INP: no task over 50ms in the interaction path;
scheduler.yield()between chunks;useDeferredValue/useTransitionon expensive renders; virtualize lists over ~200 rows; no synchronous storage or XHR in handlers; third-party tags loaded after first input or in a worker. - LCP: hero present in server HTML;
fetchpriority="high"+ preload + responsivesrcset; noloading="lazy"above the fold; critical CSS inlined; hydration deferred for non-critical widgets; TTFB under 800ms via CDN/edge caching. - Observability: LoAF and Event Timing collectors in production; LCP subparts segmented by route and device; alert on p75, not averages.
Pitfalls That Invalidate Your Numbers
- Reporting INP from a lab script with one synthetic click — INP is a p98 interaction metric, not a first-interaction metric.
- Reading
event.durationwithout checkinginteractionId(Chrome 111+PerformanceEventTiming.interactionId), which includes non-interaction events in your percentile. - Optimizing the LCP image when the dominant subpart is TTFB or element render delay.
- Attaching
will-changepermanently to dozens of elements, forcing layer explosion and memory pressure. - Never disconnecting performance observers, leaking work into every subsequent interaction.
Sources & Technical References
- web.dev — Interaction to Next Paint (INP)
- web.dev — Optimize INP
- web.dev — Largest Contentful Paint (LCP)
- web.dev — Optimize LCP
- MDN — PerformanceEventTiming
- MDN — PerformanceLongAnimationFrameTiming
- web.dev — Long Animation Frames API
- MDN — Scheduler interface
- Chrome for Developers — scheduler.yield()
- W3C — Event Timing API Specification
- W3C — Largest Contentful Paint Specification
- W3C — Long Animation Frames Specification
- GoogleChrome/web-vitals — Attribution build documentation
- MDN — Speculation Rules API
- Chrome for Developers — Soft Navigations experiment
- RFC 8297 — Indicating Hints (HTTP 103 Early Hints)
- React Documentation — useTransition
- MDN — content-visibility
Discussion (0)
Continue Reading
More production-grade engineering deep dives and architectural guides
Biggest Hack in the World: Binance Suffers Unprecedented $6.3 Billion Multi-Chain Cyberattack
A comprehensive technical autopsy of the $6.3B Binance hot-wallet breach attributed to TraderTraitor (Lazarus Group). How automated sweeping, compromised DevOps credentials, and cross-chain execution sparked the largest heist in financial history.

Mobile Push at 100M Devices: Benchmarking FCM, APNs, and Custom WebSockets
At 100M devices, push fails in the tail, not the median. We break down real delivery-latency percentiles for APNs, FCM, and owned WebSockets, plus a reproducible cost model and the reliability patterns behind 99.99%.

Landing Page Experimentation at 50M Sessions: Client-Side A/B Testing vs. Edge Decisioning vs. Server-Side Flags — Flicker, INP, and Cost per Million Decisions Benchmarked
At 50 million monthly sessions, where your A/B test makes its decision stops being a tooling preference and becomes a Core Web Vitals and P&L decision. This is a head-to-head benchmark of client-side DOM mutation, edge middleware bucketing, and server-side feature flags — measured on CLS flicker, INP, and true cost per million decisions.