All ArticlesWeb Performance•November 1, 2026•10 min read

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.

A laptop displaying an analytics dashboard with real-time data tracking and analysis tools.Photo by Atlantic Ambience on Pexels

Why 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

MetricGoodNeeds improvementPoor
LCP≤ 2.5 s2.5 – 4.0 s> 4.0 s
INP≤ 200 ms200 – 500 ms> 500 ms
CLS≤ 0.10.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:

code
LCP = TTFB + resource load delay + resource load duration + element render delay

Chrome's guidance assigns each subpart a rough share of the total budget:

SubpartTarget shareTypical 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.

ts
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:

html
<!-- 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" />
html
<!-- 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 @import in 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:

code
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.

ts
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.

ts
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.

tsx
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.

ts
// 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:

ts
// ❌ 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:

ts
// ✅ 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:

  1. CrUX attributes all route performance to the initial hard navigation.
  2. Your first route's LCP may be excellent while /dashboard takes 4 s — and no field tool will tell you.

Mitigation: measure per-route yourself and attach location.pathname as a dimension.

ts
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 never loading="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/attribution reporting 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-display can 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

0 views•0 comments

Discussion (0)

Join the conversation

Loading comments...

Continue Reading

More production-grade engineering deep dives and architectural guides

Explore all articles