Optimizing INP and LCP for Modern Single Page Applications: A Production Playbook
SPAs routinely ship great UX and failing Core Web Vitals. This playbook breaks down how INP and LCP are actually computed in client-rendered apps, then shows the scheduling, rendering, and RUM instrumentation patterns that keep both metrics green in the field.
Photo by Daniil Komov on PexelsA single page application can feel instant and still fail Core Web Vitals. The JavaScript bundle that makes routing seamless is the same bundle that queues behind the user's first tap; the client-rendered shell that eliminates full page reloads is the same shell that pushes the largest hero paint behind a fetch waterfall. INP and LCP are not polish metrics for SPAs — they are a direct measurement of how much architecture you shipped to the client.
This is a practitioner's playbook: how INP and LCP are actually computed, where SPAs structurally lose the budget, and the production-grade patterns — scheduling, transitions, containment, priority hints, and RUM attribution — that hold the line at the 75th percentile.
The 2024+ Baseline: What You Are Actually Being Graded On
Core Web Vitals are evaluated at the 75th percentile of page loads, segmented by mobile and desktop, using field data (CrUX or your own RUM). Lab tools cannot grade you — Lighthouse reports a Total Blocking Time proxy, not INP.
| Metric | Good | Needs improvement | Poor |
|---|---|---|---|
| LCP | ≤ 2.5 s | 2.5 s – 4.0 s | > 4.0 s |
| INP | ≤ 200 ms | 200 ms – 500 ms | > 500 ms |
| CLS | ≤ 0.1 | 0.1 – 0.25 | > 0.25 |
INP replaced First Input Delay in March 2024. This matters enormously for SPAs: FID measured only the input delay before your handler ran, while INP measures the entire interval until the browser paints the next frame. Teams that optimized for FID by "keeping handlers light" measured the wrong thing — the cost is almost always in the render work after the handler returns.
Deconstructing INP: The Three Latency Buckets
An interaction starts at the OS input timestamp and ends at the next paint that reflects it. That interval decomposes into exactly three buckets, and every optimization you make targets one of them.
Input delay — the main-thread queue you inherit
The gap between the user's tap and your first line of handler code running. It is non-zero whenever a long task (>50 ms) owns the main thread when the input arrives. In SPAs the usual culprits are hydration, synchronous route transitions, third-party tag managers, and large JSON parse + render loops.
Processing time — your handler, your framework, your DOM
From your handler's first statement to its last, including React's reconciliation and commit, style recalculation, layout, and any forced synchronous layout ("layout thrash") you trigger by interleaving DOM reads and writes.
Presentation delay — paint is a promise
From the end of processing to the frame that actually shows the result. Long style/layout trees, expensive paint (large blurs, box-shadows, uncontained subtrees), and compositor work all inflate this bucket. A 12,000-node table can add 100 ms of layout here with a handler that does essentially nothing.
Attribution rule of thumb: if input delay dominates, fix scheduling (long tasks). If processing dominates, fix work per interaction (reconciliation, DOM size). If presentation dominates, fix rendering (containment, paint cost).
The INP Fix Playbook
1. Yield deliberately — and use primitives that actually yield
setTimeout(fn, 0) is clamped to ~4 ms after nested timers and is not a reliable cooperator. requestIdleCallback does not fire while the user is interacting — exactly when you need to yield. Use scheduler.yield() (Chromium) with a MessageChannel fallback, which yields to the event loop without the timer clamp:
// lib/scheduler/yield.ts
type YieldFn = () => Promise<void>;
interface SchedulerLike {
yield?: () => Promise<void>;
}
const nativeYield = (globalThis as { scheduler?: SchedulerLike }).scheduler?.yield;
// MessageChannel survives as the cross-browser fallback: no 4ms timer clamp.
const channel = new MessageChannel();
const waiters: Array<() => void> = [];
channel.port1.onmessage = () => waiters.shift()?.();
const messageYield = (): Promise<void> =>
new Promise<void>((resolve) => {
waiters.push(resolve);
channel.port2.postMessage(null);
});
export const yieldToMain: YieldFn = nativeYield ? () => nativeYield() : messageYield;
/**
* Process a list in ~budgetMs slices, yielding between slices so that
* incoming input can be handled with minimal input delay.
*/
export async function chunkedForEach<T>(
items: readonly T[],
work: (item: T, index: number) => void,
budgetMs = 5,
signal?: AbortSignal,
): Promise<void> {
let sliceStart = performance.now();
for (let i = 0; i < items.length; i++) {
if (signal?.aborted) return;
work(items[i] as T, i);
if (performance.now() - sliceStart >= budgetMs) {
await yieldToMain();
sliceStart = performance.now();
}
}
}Apply the same idea at the framework boundary. Long JSON hydration, markdown rendering, and analytics enrichment loops are the three most common places I find 300–800 ms tasks in React and Vue apps.
2. Keep urgent work urgent — transitions and deferred values
The interaction handler should do the minimum synchronous work: capture the value, update the input, then defer the expensive derived render. In React 18+, useDeferredValue and startTransition give you exactly that split without debouncing (debouncing adds latency for fast typists and does not reduce per-event INP — the debounce timer itself is inside the measured interaction).
import { memo, useDeferredValue, useMemo, useState } from 'react';
const Row = memo(function Row({ text }: { text: string }) {
return <li>{text}</li>;
});
export function SearchableTable({ rows }: { rows: readonly string[] }) {
const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query);
const filtered = useMemo(() => {
const needle = deferredQuery.toLowerCase();
return needle ? rows.filter((r) => r.toLowerCase().includes(needle)) : rows;
}, [rows, deferredQuery]);
return (
<section>
{/* Urgent: a single state update, committed in the same frame. */}
<input
value={query}
onChange={(e) => setQuery(e.currentTarget.value)}
aria-label="Filter rows"
/>
<ul style={{ opacity: query === deferredQuery ? 1 : 0.6 }}>
{filtered.map((r) => (
<Row key={r} text={r} />
))}
</ul>
</section>
);
}The same mechanism applies to routing: wrap the state update that swaps routes in startTransition so a slow-rendering page does not block the click feedback. Combine it with intent-based prefetching so the code is already parsed when the transition runs:
// lib/route-prefetch.ts
const loaders: Record<string, () => Promise<unknown>> = {
'/reports': () => import('../pages/Reports'),
'/settings': () => import('../pages/Settings'),
};
const inflight = new Map<string, Promise<unknown>>();
export function prefetchRoute(path: string): void {
const load = loaders[path];
if (!load || inflight.has(path)) return;
inflight.set(
path,
load().catch(() => {
// Network hiccup or a redeploy: the real navigation will retry.
inflight.delete(path);
}),
);
}
// Wire to pointerenter/focus on nav links — never to page load.3. Shrink the interaction surface — DOM performance
DOM size is an INP multiplier: style recalculation and layout are roughly linear in the number of nodes, so a 5,000-node view turns a 2 ms handler into a 60 ms interaction. Two levers, both boring and both effective:
- Windowize long lists (TanStack Virtual, react-window) so the DOM stays in the hundreds of nodes.
- Contain subtrees so the browser can skip work outside them.
/* Off-screen rows skip layout/paint entirely, while the intrinsic size
keeps scroll height and prevents CLS. */
.virtual-row {
content-visibility: auto;
contain-intrinsic-size: 0 48px;
}
/* A chat panel cannot affect outer layout: contain the blast radius. */
.chat-panel {
contain: layout paint style;
}Deconstructing LCP in a Client-Rendered Shell
LCP is the render time of the largest image or text block in the viewport, relative to navigation start. It decomposes into four sub-parts, each with a distinct fix:
- Time to First Byte — server/CDN: cache HTML at the edge, stream it, send
103 Early Hints. - Resource load delay — the gap between TTFB and when the browser discovers the LCP resource. This is where SPAs hemorrhage: the hero image URL lives inside a JS bundle or a client-side JSON fetch.
- Resource load duration — bytes over the wire: AVIF/WebP, responsive
srcset, CDN compression. - Element render delay — time from resource loaded to painted: blocked by CSS, fonts, or hydration.
Ship the hero in the HTML, not in the bundle
The single most damaging SPA anti-pattern is an SSR/SSG shell that renders the hero image only after hydration and a data fetch. Server-render the LCP element and make it discoverable in the initial HTML with a priority hint:
<link
rel="preload"
as="image"
href="/hero-800.avif"
imagesrcset="/hero-480.avif 480w, /hero-800.avif 800w, /hero-1600.avif 1600w"
imagesizes="100vw"
fetchpriority="high"
/>
<img
src="/hero-800.avif"
srcset="/hero-480.avif 480w, /hero-800.avif 800w, /hero-1600.avif 1600w"
sizes="100vw"
width="1600"
height="900"
fetchpriority="high"
loading="eager"
decoding="async"
alt="Quarterly revenue dashboard"
/>Never put loading="lazy" on the LCP image — a genuine, recurring regression caused by applying the "defer offscreen images" Lighthouse audit to above-the-fold heroes. Reserve dimensions to keep CLS at 0, and if the LCP element is text, font-display: swap plus a preloaded subset font beats a hidden-then-painted headline.
Soft navigations do not report LCP
The LargestContentfulPaint API reports one LCP candidate per hard navigation. SPA route changes inside the same document are not captured by it and are not yet part of CrUX scoring; they require the experimental Soft Navigations API. Do not celebrate a green LCP if your users spend their time in soft navigations — measure those route transitions with your own marks (see the instrumentation section).
Instrumenting INP Attribution in Production
Field data is the only source of truth, and unattributed INP numbers are unactionable. Ship the web-vitals attribution build and log the sub-parts:
// lib/rum/vitals.ts
import { onCLS, onINP, onLCP, onTTFB } from 'web-vitals/attribution';
import type { Metric } from 'web-vitals';
type VitalsRecord = {
name: Metric['name'];
value: number;
rating: Metric['rating'];
id: string;
route: string;
attribution: Record<string, unknown>;
};
const ENDPOINT = '/api/rum/vitals';
const queue: VitalsRecord[] = [];
let flushTimer: number | undefined;
function flush(): void {
if (queue.length === 0) return;
const body = JSON.stringify(queue.splice(0, queue.length));
if (navigator.sendBeacon) {
navigator.sendBeacon(ENDPOINT, new Blob([body], { type: 'application/json' }));
return;
}
void fetch(ENDPOINT, {
method: 'POST',
body,
keepalive: true,
headers: { 'Content-Type': 'application/json' },
}).catch(() => {
/* Telemetry must never break the app. */
});
}
function record(metric: Metric & { attribution?: Record<string, unknown> }): void {
queue.push({
name: metric.name,
value: Math.round(metric.value),
rating: metric.rating,
id: metric.id,
route: window.location.pathname,
attribution: metric.attribution ?? {},
});
if (flushTimer) clearTimeout(flushTimer);
flushTimer = window.setTimeout(flush, 2000);
}
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') flush();
});
onLCP(record);
onINP(record);
onCLS(record);
onTTFB(record);For local debugging, subscribe directly to the Event Timing API — this is what the DevTools Interactions track renders, and it gives you the exact bucket breakdown for every slow interaction:
new PerformanceObserver((list) => {
for (const entry of list.getEntries() as PerformanceEventTiming[]) {
if (entry.duration < 200 || entry.interactionId === 0) continue;
console.debug('[INP candidate]', {
event: entry.name,
duration: Math.round(entry.duration),
inputDelay: Math.round(entry.processingStart - entry.startTime),
processing: Math.round(entry.processingEnd - entry.processingStart),
presentation: Math.round(entry.startTime + entry.duration - entry.processingEnd),
target: entry.target instanceof Element ? entry.target.tagName : 'unknown',
});
}
}).observe({ type: 'event', durationThreshold: 104, buffered: true });Production Hardening Checklist
- Route-level code splitting only — no "split everything" waterfalls that delay the hero on navigation.
startTransitionaround route swaps and non-urgent list renders.- No task longer than 50 ms during hydration; audit with the DevTools Performance panel's Interactions track.
- Third-party scripts deferred or replaced with facades (video embeds, chat widgets, consent banners).
- LCP element present in server-rendered HTML with
fetchpriority="high"; preloaded withimagesrcset. - Virtualized lists and
content-visibility: autoon long, off-screen regions. - RUM reporting INP attribution sub-parts segmented by route and device class.
- Regression gates: fail CI when the p75 INP or LCP of canary traffic crosses threshold.
Pitfalls That Quietly Cancel Your Gains
- Optimizing for FID habits. Removing "expensive" handlers does nothing if the render after them is the cost.
- Debouncing to fix INP. INP measures the interaction, not the eventual result; debouncing often increases perceived and measured latency.
- Lazy-loading the hero. The most common self-inflicted LCP regression in component libraries.
- Celebrating hard-navigation LCP. Soft navigations are invisible to the LCP API and to CrUX today.
- Trusting lab-only numbers. Lighthouse cannot measure INP; a green lab report coexists happily with a 420 ms field INP.
- Ignoring `presentation`. A heavy, uncontained DOM inflates the bucket no handler optimization can reach.
Sources & Technical References
- web.dev — Interaction to Next Paint (INP): https://web.dev/articles/inp
- web.dev — Optimize INP: https://web.dev/articles/optimize-inp
- web.dev — Largest Contentful Paint (LCP): https://web.dev/articles/lcp
- web.dev — Optimize LCP: https://web.dev/articles/optimize-lcp
- web.dev — Optimize long tasks: https://web.dev/articles/optimize-long-tasks
- W3C — Event Timing API specification: https://w3c.github.io/event-timing/
- W3C — Long Tasks API specification: https://w3c.github.io/longtasks/
- W3C — Largest Contentful Paint specification: https://w3c.github.io/largest-contentful-paint/
- MDN — PerformanceEventTiming: https://developer.mozilla.org/en-US/docs/Web/API/PerformanceEventTiming
- MDN — Scheduler.yield(): https://developer.mozilla.org/en-US/docs/Web/API/Scheduler/yield
- MDN — content-visibility: https://developer.mozilla.org/en-US/docs/Web/CSS/content-visibility
- GoogleChrome — web-vitals library (attribution build): https://github.com/GoogleChrome/web-vitals
- Chrome for Developers — Speculation Rules API: https://developer.chrome.com/docs/web-platform/prerender-pages
- RFC 8297 — An HTTP Status Code for Indicating Hints (103 Early Hints): https://www.rfc-editor.org/rfc/rfc8297
- React Documentation — useDeferredValue and startTransition: https://react.dev/reference/react/useDeferredValue
Discussion (0)
Continue Reading
More production-grade engineering deep dives and architectural guides

Mastering Incremental Static Regeneration and Stale-While-Revalidate in Next.js
ISR is not a magic freshness switch — it is a two-layer cache contract between your framework and the CDN. Learn how to wire tag-based revalidation, align stale-while-revalidate windows, and avoid the production pitfalls that silently break caching.
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%.