All ArticlesWeb Performance•November 3, 2026•11 min read

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.

Optimizing Interaction to Next Paint (INP) and LCP for Modern Single Page ApplicationsAI Generated with Qwen 3.0 via CometAPI

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

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

ts
// 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-vitals v3 exposed these as timeToFirstByte, resourceLoadDelay, resourceLoadDuration, and elementRenderDelay. 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.

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

tsx
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

ts
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

css
/* 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.

html
<!-- 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"
/>
html
<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.

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

ts
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

  1. Baseline from CrUX + RUM, segmented by route and device class. Aggregate p75 across an entire SPA hides the one screen that is failing.
  2. Set a long-task budget: zero tasks > 200 ms on mid-tier Android; < 50 ms during interaction windows.
  3. Audit third parties weekly. Tag managers are the most common source of unattributable input delay; load them after first interaction where possible.
  4. Preload the LCP asset per route and assert it exists in the initial HTML in a CI check.
  5. Guard hydration cost with partial hydration or islands for non-interactive sections.
  6. Add Lighthouse CI budgets for LCP/TBT, and a bundle-size budget per route chunk.
  7. Verify field data after release with reportAllChanges: true in 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

5 views•0 comments

Discussion (0)

Join the conversation

Loading comments...

Continue Reading

More production-grade engineering deep dives and architectural guides

Explore all articles