Optimizing Interaction to Next Paint (INP) and LCP for Modern Single Page Applications
Single page applications fail Core Web Vitals in ways that static sites never do: the LCP element is rendered by JavaScript, and every route transition competes with hydration for the main thread. This playbook breaks INP and LCP into their measurable sub-phases and gives you production-grade instrumentation, yielding primitives, and preload strategies to fix them.
AI Generated with Qwen 3.0 via CometAPIOptimizing Interaction to Next Paint (INP) and LCP for Modern Single Page Applications
Interaction to Next Paint replaced First Input Delay in March 2024, and it changed the economics of front-end performance. FID only measured the delay before your handler ran; INP measures the entire interaction lifecycle, from the moment a user presses a key to the moment the next frame is painted. For a statically rendered marketing page that is a forgiving metric. For a hydrated React, Vue, or Angular application running on a mid-tier Android device, it is a forensic audit of everything your main thread does.
This article treats INP and LCP as engineering problems with decomposable sub-phases, measurable in the field, and fixable with specific primitives. Every technique below is something you can ship today.
Why SPAs Break These Vitals Differently
Static sites typically fail LCP on the network: a slow TTFB or an unoptimized hero image. SPAs fail LCP on the main thread: the largest element often does not exist in the initial HTML, so the browser cannot even discover it until the bundle parses, the framework boots, the data fetch resolves, and the first render commits. That sequence inflates render delay, the phase that is hardest to fix after the fact.
INP fails for the mirror-image reason. The same main thread that must paint the next frame after a click is also busy hydrating, re-rendering lists, and executing third-party tags. Long tasks create input delay; expensive handlers create processing duration; synchronous layout and oversized DOM diffs create presentation delay.
| 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 |
One nuance matters for SPAs: INP is not simply the worst interaction on the page. On pages with dense interaction counts it is reported at a high percentile — roughly the 98th — so a single pathological handler attached to a frequently used control (a search input, a filter chip, a nav toggle) will dominate your score even if every other interaction is instant.
Anatomy of an Interaction: Where the 200 ms Goes
Input delay — the main thread owes the user a frame
Input delay is the gap between the OS-level event and the moment your handler starts executing. It is almost entirely caused by other work occupying the main thread: a 300 ms hydration task, a synchronous localStorage read on boot, a tag manager injecting a chat widget. Fixes are structural — break up the blocking work, or move it off the thread entirely.
Processing duration — your handler's own cost
This is the time your event callbacks run, including every useState update, every reducer, and every synchronous DOM read/write they trigger. Interleaving reads and writes (element.offsetHeight inside a loop that also mutates styles) forces repeated layout and can 10× the cost of an otherwise trivial update.
Presentation delay — the browser's bill
After your handler returns, the browser must recalculate style, lay out, and paint. Large DOM trees, deeply nested selectors, and expensive paint areas all extend this phase. This is where CSS-level interventions such as content-visibility and containment pay off.
Instrumenting INP Attribution in Production
Lab tools tell you that an interaction was slow. Field data tells you which interaction, on which route, on which device class. Use the attribution build of web-vitals v4, which exposes the sub-phase breakdown per interaction.
// rum/vitals.ts — attribution-grade field collection for SPA routes
import { onLCP, onINP } from 'web-vitals/attribution';
import type { LCPMetricWithAttribution, INPMetricWithAttribution } from 'web-vitals/attribution';
type VitalsPayload = {
name: 'LCP' | 'INP';
value: number;
rating: 'good' | 'needs-improvement' | 'poor';
id: string;
route: string;
navigationType: string;
attribution: Record<string, unknown>;
};
const ENDPOINT = '/api/rum/vitals';
const routeOf = (): string => location.pathname.replace(/\/\d+/g, '/:id');
function describeElement(el: Element | null): string {
if (!el) return 'unknown';
const id = el.id ? `#${el.id}` : '';
const cls =
typeof el.className === 'string' && el.className
? `.${el.className.trim().split(/\s+/).slice(0, 2).join('.')}`
: '';
return `${el.tagName.toLowerCase()}${id}${cls}`;
}
function report(payload: VitalsPayload): void {
const body = JSON.stringify(payload);
try {
// sendBeacon survives page dismissal; prefer it over fetch on unload.
const queued = navigator.sendBeacon(ENDPOINT, new Blob([body], { type: 'application/json' }));
if (!queued) throw new Error('beacon rejected by UA');
} catch {
void fetch(ENDPOINT, {
method: 'POST',
body,
headers: { 'content-type': 'application/json' },
keepalive: true,
}).catch((err: unknown) => console.warn('[rum] vitals dropped', err));
}
}
onLCP((m: LCPMetricWithAttribution) => {
const entry = m.attribution.lcpEntry;
report({
name: 'LCP', value: m.value, rating: m.rating, id: m.id,
route: routeOf(), navigationType: m.navigationType,
attribution: {
element: describeElement(entry?.element ?? null),
url: entry?.url ?? '',
size: entry?.size ?? 0,
ttfb: m.attribution.ttfb,
loadDelay: m.attribution.loadDelay,
loadDuration: m.attribution.loadDuration,
renderDelay: m.attribution.renderDelay,
},
});
});
onINP((m: INPMetricWithAttribution) => {
report({
name: 'INP', value: m.value, rating: m.rating, id: m.id,
route: routeOf(), navigationType: m.navigationType,
attribution: {
interactionType: m.attribution.interactionType,
target: describeElement(m.attribution.interactionTarget),
inputDelay: m.attribution.inputDelay,
processingDuration: m.attribution.processingDuration,
presentationDelay: m.attribution.presentationDelay,
},
});
});Note:
web-vitalsv3 exposed these astimeToFirstByte,resourceLoadDelay,resourceLoadDuration, andelementRenderDelay. Rename accordingly if you are still on v3.
With route attached, you can rank routes by p75 INP and stop guessing which screen is expensive.
Killing Long Tasks Without Breaking UX
The single highest-leverage INP fix is eliminating tasks over 50 ms. You cannot fix a long task by making it faster in general — you must break it up so the browser can interleave input and rendering.
// scheduler/yield.ts
type TaskPriority = 'user-blocking' | 'user-visible' | 'background';
interface SchedulerLike {
yield?: () => Promise<void>;
postTask?: <T>(cb: () => T, opts?: { priority?: TaskPriority; signal?: AbortSignal }) => Promise<T>;
}
const scheduler = (globalThis as unknown as { scheduler?: SchedulerLike }).scheduler;
/**
* Yields to the browser so pending input and rendering can run.
* Falls back from scheduler.yield -> scheduler.postTask -> a macrotask.
*/
export async function yieldToMainThread(): Promise<void> {
if (scheduler?.yield) return scheduler.yield();
if (scheduler?.postTask) return scheduler.postTask(() => undefined, { priority: 'user-visible' });
await new Promise<void>((resolve) => setTimeout(resolve, 0));
}
/**
* Processes a workload in time-boxed chunks, yielding when the frame budget
* is exhausted. Abortable so route changes can cancel in-flight work.
*/
export async function runInChunks<T>(
items: readonly T[],
handle: (item: T, index: number) => void,
options: { chunkSize?: number; maxFrameMs?: number; signal?: AbortSignal } = {},
): Promise<void> {
const { chunkSize = 40, maxFrameMs = 8, signal } = options;
let frameStart = performance.now();
for (let i = 0; i < items.length; i += 1) {
if (signal?.aborted) throw new DOMException('Task aborted', 'AbortError');
handle(items[i] as T, i);
const spent = performance.now() - frameStart;
if (spent >= maxFrameMs || (i + 1) % chunkSize === 0) {
await yieldToMainThread();
frameStart = performance.now();
}
}
}Critically, scheduler.yield() resumes with higher priority than a macrotask — it is not setTimeout(fn, 0), which queues behind timers and can starve. A cascade of setTimeout(0) calls is itself a performance anti-pattern: each one incurs clamping and scheduling overhead. Use yield where available.
Keeping the Main Thread Free: Transitions, Workers, and CSS
Defer expensive renders with concurrent React
The canonical SPA INP bug is a controlled input whose onChange filters 10,000 rows synchronously. The keystroke's processing duration includes the entire filter and reconciliation.
import { useDeferredValue, useMemo, useState } from 'react';
type Row = { id: string; label: string; score: number };
export function SearchableTable({ rows }: { rows: readonly Row[] }) {
const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query);
const isStale = query !== deferredQuery;
const filtered = useMemo(() => {
const q = deferredQuery.trim().toLowerCase();
return q ? rows.filter((r) => r.label.toLowerCase().includes(q)) : rows;
}, [rows, deferredQuery]);
return (
<>
<input
value={query}
onChange={(e) => setQuery(e.target.value)} // urgent: keeps the caret responsive
aria-label="Filter rows"
/>
<table aria-busy={isStale} style={{ opacity: isStale ? 0.6 : 1 }}>
{/* render filtered */}
</table>
</>
);
}useDeferredValue lets React commit the small, urgent input update first and the expensive list render at lower priority — exactly the split INP rewards.
Offload CPU-bound work to a worker
export function createSearcher() {
const worker = new Worker(new URL('./search.worker.ts', import.meta.url), { type: 'module' });
const pending = new Map<number, { resolve: (rows: unknown[]) => void; reject: (e: Error) => void }>();
let seq = 0;
worker.onmessage = (e: MessageEvent<{ id: number; matches: unknown[] }>) => {
const p = pending.get(e.data.id);
if (!p) return;
pending.delete(e.data.id);
p.resolve(e.data.matches);
};
worker.onerror = () => {
for (const p of pending.values()) p.reject(new Error('search worker crashed'));
pending.clear();
};
return {
search(rows: readonly unknown[], query: string): Promise<unknown[]> {
const id = seq++;
return new Promise((resolve, reject) => {
pending.set(id, { resolve, reject });
worker.postMessage({ id, rows, query });
});
},
dispose(): void { worker.terminate(); pending.clear(); },
};
}Structured-clone cost is real: shipping a 200k-row array to a worker per keystroke will hurt. Send once, cache worker-side, and post only the query.
Constrain layout and paint scope
/* Below-the-fold cards skip layout/paint work until scrolled near. */
.card-list > .card {
content-visibility: auto;
contain-intrinsic-size: auto 220px; /* prevents scrollbar and CLS jank */
}Never apply content-visibility: auto to a container that holds your LCP element — it can push the paint out and inflate render delay.
The LCP Critical Path for Client-Rendered Routes
For an SPA, your goal is to make the hero element discoverable in the initial HTML, not after hydration.
<!-- index.html: the hero must be preloaded before the bundle parses -->
<link
rel="preload"
as="image"
href="/media/hero-1200.avif"
imagesrcset="/media/hero-640.avif 640w, /media/hero-1200.avif 1200w, /media/hero-1920.avif 1920w"
imagesizes="(max-width: 720px) 100vw, 1200px"
type="image/avif"
fetchpriority="high"
/><img
src="/media/hero-1200.avif"
srcset="/media/hero-640.avif 640w, /media/hero-1200.avif 1200w, /media/hero-1920.avif 1920w"
sizes="(max-width: 720px) 100vw, 1200px"
width="1200" height="630"
alt="Product analytics dashboard"
fetchpriority="high"
loading="eager"
decoding="async"
/>The three rules that matter most: (1) never set loading="lazy" on the LCP image; (2) never inject the LCP image from client JavaScript after a data fetch; (3) server-render a text-based or image-based hero above the fold so the browser paints something meaningful from the first HTML bytes. If SSR is not feasible, streaming SSR or static shells with per-route prerendering are the pragmatic middle ground.
Soft Navigations: The Blind Spot in Your LCP Data
A route transition in an SPA is not a navigation as far as the browser is concerned — TTFB and FCP are not re-recorded, and your route-level LCP is invisible to standard RUM. The experimental Soft Navigation API in Chromium addresses this; treat it as progressive enhancement.
export function observeSoftNavigations(onNav: (url: string) => void): () => void {
if (typeof PerformanceObserver === 'undefined') return () => {};
try {
const po = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) onNav(entry.name || location.pathname);
});
po.observe({ type: 'soft-navigation', buffered: true } as PerformanceObserverInit);
return () => po.disconnect();
} catch {
// Firefox/Safari do not implement soft navigations — degrade silently.
return () => {};
}
}Pair this with route-scoped custom timing marks around your data fetch and first commit, so you can reconstruct a per-route LCP approximation regardless of browser support.
Diagnosing With LoAF and the Performance Panel
Long Animation Frames (LoAF) attribute a long frame to the scripts responsible — including third-party scripts you do not own. This is the fastest path from "INP is 480 ms" to "the chat widget's invoker: MessagePort.onmessage costs 210 ms."
interface LoAFAggregate { blockingDuration: number; scripts: string[] }
export function observeLoAF(onFrame: (agg: LoAFAggregate) => void): () => void {
try {
const po = new PerformanceObserver((list) => {
for (const entry of list.getEntries() as PerformanceLongAnimationFrameTiming[]) {
onFrame({
blockingDuration: entry.blockingDuration,
scripts: entry.scripts
.filter((s) => s.duration > 30)
.map((s) => `${s.invoker || 'unknown'} @ ${s.sourceURL || 'inline'} — ${Math.round(s.duration)}ms`),
});
}
});
po.observe({ type: 'long-animation-frame', buffered: true });
return () => po.disconnect();
} catch {
return () => {};
}
}In DevTools, use the Performance panel's INP insights to jump directly to the slowest interactions recorded in the trace, and cross-reference with the Timings track to see whether a click landed during a hydration burst.
Operational Checklist Before You Ship
- Baseline from CrUX + RUM, segmented by route and device class. Aggregate p75 across an entire SPA hides the one screen that is failing.
- Set a long-task budget: zero tasks > 200 ms on mid-tier Android; < 50 ms during interaction windows.
- Audit third parties weekly. Tag managers are the most common source of unattributable input delay; load them after first interaction where possible.
- Preload the LCP asset per route and assert it exists in the initial HTML in a CI check.
- Guard hydration cost with partial hydration or islands for non-interactive sections.
- Add Lighthouse CI budgets for LCP/TBT, and a bundle-size budget per route chunk.
- Verify field data after release with
reportAllChanges: truein a canary cohort before enabling for all users.
Pitfalls That Quietly Wreck Your Numbers
- Optimizing only in the lab. A fast desktop trace with 0 ms network throttling will not surface input delay from a mid-tier device.
- Lazy-loading the hero. The most common self-inflicted LCP regression in component libraries that default to
loading="lazy". - `content-visibility: auto` above the fold. Defers the very paint you are measuring.
- `setTimeout(0)` yielding storms. Thousands of timer hops add scheduling overhead and lose priority; prefer
scheduler.yield(). - Debouncing instead of deferring. Debouncing delays the state update but keeps the expensive render on the critical path.
- Over-virtualizing. Windowing a 40-row list adds measurement cost and hurts a11y; move to a worker first.
- Measuring INP with tiny samples. Interaction counts vary wildly by route; a p75 from 12 samples is noise.
Sources & Technical References
- web.dev — Interaction to Next Paint (INP)
- web.dev — Optimize INP
- web.dev — Largest Contentful Paint (LCP)
- web.dev — Optimize LCP
- GoogleChrome — web-vitals library (attribution build)
- W3C — Event Timing API specification
- W3C — Long Tasks API specification
- W3C — Long Animation Frames specification
- W3C — Largest Contentful Paint specification
- WICG — Scheduling APIs (`scheduler.yield`, `scheduler.postTask`)
- MDN — Scheduler.yield()
- MDN — PerformanceObserver
- MDN — HTML `fetchpriority` attribute
- MDN — CSS `content-visibility`
- Chrome for Developers — Soft navigations (experimental)
- Chrome for Developers — Chrome UX Report (CrUX)
- React — `useDeferredValue` reference
Discussion (0)
Continue Reading
More production-grade engineering deep dives and architectural guides

1B Spans/Day in Go: OTel SDK vs. eBPF vs. Hand-Rolled Tracing — Measured p99, CPU per Span, and Real Cost per 1M Spans
A billion spans a day sounds like a hyperscaler number — it is really just ~2,900 RPS. We benchmarked the OpenTelemetry Go SDK, eBPF auto-instrumentation, and a hand-rolled arena tracer on the same gRPC service to separate instrumentation CPU cost from the number that actually destroys observability budgets: storage and indexing per 1M spans.

Kubernetes Autoscaling at 10k Pods: Karpenter vs Cluster Autoscaler vs CAST AI, Measured
We ran identical HPA-driven load ramps from 2,000 to 10,000 pods across three EKS clusters to measure p99 scale-up latency, bin-packing waste, spot interruption recovery, and true cost per 1,000 pod-hours. Here is the methodology, the raw numbers, and the architectural reasons behind them.

Component Testing at 1M Runs/Month: Playwright CT vs Cypress CT vs Vitest Browser Mode — Measured p95, Flake Rate, and True CI Cost
We executed one million component test runs across Playwright CT, Cypress CT, and Vitest browser mode on identical hardware and measured p95 wall time, first-attempt flake rate, and throughput. The surprising result: CI compute is under 0.5% of your real bill — flake triage is the other 98%.