Improving INP on JavaScript-Heavy SPA Pages

A JavaScript-heavy single-page app does its most expensive work β€” hydration β€” at the exact moment an eager user first taps, and the tap has to wait. This guide, part of Core Web Vitals for client-side apps, explains why long tasks block interaction and how breaking up work, yielding, and deferring restore a responsive Interaction to Next Paint (INP).

Problem Statement

INP measures the latency from a user interaction to the next frame the browser paints in response. The browser is single-threaded for JavaScript: while a task runs, no input is processed and no frame is painted. Any block of work that runs 50 milliseconds or longer without returning control is a long task, and any interaction that lands while one is executing is stuck behind it.

In a SPA the worst offender is hydration. After the initial paint, the framework walks the entire component tree to attach event handlers and rebuild client state, and it typically does so in a small number of large, uninterrupted tasks. That is the responsiveness cliff: the page looks ready, the user taps, and the tap sits in the queue until hydration yields.

The other main contributors compound the problem:

  • Heavy event handlers. A click handler that does layout-thrashing work or synchronous serialization blocks the paint it should trigger.
  • Non-critical third parties. Analytics, tag managers, and chat widgets execute on the same thread, extending the busy window into the interaction period.
  • Monolithic hydration. Hydrating everything at once β€” including below-the-fold and rarely-used widgets β€” spends the interaction budget on parts of the page the user is not touching.

Reducing INP is therefore about getting off the main thread more often: split the long tasks, yield between chunks so a pending tap can slip through, and stop doing non-essential work during the window when the user first interacts.

One long hydration task versus chunked work that yields for input The top track shows a tap blocked behind a single long task and a late paint; the bottom track splits the work into chunks that yield, letting the tap be handled and painted quickly. One long task β€” the tap waits, paint is late hydration long task (main thread blocked) tap late paint = high INP Chunked work that yields β€” the tap slips through chunk chunk tap handle + paint resume chunks low INP
Splitting the long task and yielding lets a pending tap be handled and painted between chunks instead of waiting for the whole job to finish.

Step-by-step fix

1. Find the long tasks that overlap interaction

Before cutting anything, observe where the thread is blocked. A PerformanceObserver on longtask entries lists every block over 50 ms with its start time, so you can see which ones land in the early interaction window.

const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    // Anything here ran >= 50ms without yielding and can block a tap.
    console.log("long task", Math.round(entry.duration), "ms at", entry.startTime);
  }
});
observer.observe({ type: "longtask", buffered: true });

2. Break long work into chunks that yield

Split a monolithic job into batches and hand control back to the browser between them. scheduler.yield is the preferred primitive: it lets the browser process a pending interaction, then resumes your work at continuation priority so it is not demoted to the back of the queue. Fall back to a setTimeout yield where it is unavailable. The distinction matters because both primitives unblock the thread, but they leave your remaining work in very different places in the queue.

Where remaining work lands: scheduler.yield versus setTimeout After yielding, scheduler.yield resumes your continuation at the front of the queue while setTimeout reschedules it behind unrelated macrotasks, delaying the rest of the work. Where your work resumes after a yield scheduler.yield β€” continuation priority yield() tap handled your continuation resumes now setTimeout β€” back of the queue setTimeout tap handled unrelated macrotasks continuation runs last Continuation priority keeps your work near the front, so a pending tap is handled and hydration still finishes.
scheduler.yield lets the browser handle the tap, then resumes your work ahead of unrelated tasks; setTimeout sends it to the back.
// A cooperative yield: prefer scheduler.yield, fall back to a macrotask.
function yieldToMain(): Promise<void> {
  if ("scheduler" in globalThis && "yield" in (globalThis as any).scheduler) {
    return (globalThis as any).scheduler.yield();
  }
  return new Promise((resolve) => setTimeout(resolve, 0));
}

async function processInChunks<T>(items: T[], work: (item: T) => void): Promise<void> {
  for (let i = 0; i < items.length; i++) {
    work(items[i]);
    // Yield every 50 items so a tap that arrives mid-loop can be handled.
    if (i % 50 === 49) await yieldToMain();
  }
}

3. Defer non-critical hydration and scripts

Do not hydrate what the user is not touching yet. Hydrate above-the-fold interactive regions eagerly, and defer the rest until the browser is idle or the region is about to enter the viewport. The same logic applies to third-party scripts β€” load them after interaction becomes possible, following deferring non-critical JavaScript for faster indexing.

// Hydrate a widget only when it nears the viewport, keeping the initial
// interaction budget for the parts of the page the user can actually reach.
function hydrateWhenVisible(el: Element, hydrate: () => void): void {
  const io = new IntersectionObserver((entries, obs) => {
    for (const entry of entries) {
      if (entry.isIntersecting) {
        hydrate();
        obs.disconnect();
      }
    }
  }, { rootMargin: "200px" });
  io.observe(el);
}

4. Keep the interaction itself cheap, and paint before heavy follow-up

An event handler should update the visible state and paint quickly, then push any expensive follow-up work behind a yield so it does not extend the interaction.

button.addEventListener("click", async () => {
  applyVisualState();     // cheap, lets the browser paint the response
  await yieldToMain();    // give the paint a chance to happen first
  await runExpensiveWork(); // heavier work runs after the user sees feedback
});

Validation

Confirm interactions are now fast:

  • Attribute the worst interaction. The web-vitals attribution build reports the INP value together with the element and the phase β€” input delay, processing, or presentation β€” so you know whether a blocked thread or a slow handler is at fault. INP is not a single number but the sum of those three phases, and knowing which one dominates tells you which fix to reach for.
The three phases of an INP measurement INP is the sum of input delay, processing time, and presentation delay; here they total 240 milliseconds, pushing the interaction past the 200 millisecond budget. The three phases INP attribution reports Input delay 90ms Processing 110ms Presentation 40ms budget 200ms thread blocked handler runs browser paints INP = 90 + 110 + 40 = 240ms β†’ over the 200ms budget
INP is not one number but three phases; attribution shows which one β€” a blocked thread, a slow handler, or a late paint β€” to fix.
import { onINP, type INPMetricWithAttribution } from "web-vitals/attribution";

onINP((metric: INPMetricWithAttribution) => {
  console.assert(metric.value < 200, `INP too slow: ${metric.value}ms`);
  console.log(metric.attribution.interactionTarget, metric.attribution.inputDelay);
}, { reportAllChanges: true });
  • Record interactions in DevTools. In the Performance panel, use the interaction track to see the delay between the input event and the paint; after chunking, the long task under the tap should be gone.
  • Watch total blocking time in CI. A lab tool cannot measure real INP, but total blocking time is a good proxy for main-thread congestion; gate it so a hydration regression fails the build, as in catching SEO regressions with Lighthouse CI.

Common Pitfalls

  • Yielding with only setTimeout. A macrotask yield unblocks the thread but reschedules your remaining work at low priority, which can starve it; prefer scheduler.yield and use setTimeout only as a fallback.
  • Hydrating the whole page at once. Monolithic hydration spends the interaction budget on regions the user is not touching; hydrate on visibility or interaction instead.
  • Blaming your own code only. Third-party tags run on the same thread and are a frequent, overlooked INP cause; audit and defer them.
  • Optimizing input delay but not processing. A fast-to-start handler that then does heavy synchronous work still delays the paint; move the heavy part behind a yield.
  • Testing on a fast machine. INP failures surface on mid-tier phones; throttle the CPU 4–6x in DevTools or the problem stays invisible until the field data arrives.
Five INP pitfalls paired with what to do instead Each responsiveness pitfall β€” a setTimeout-only yield, hydrating everything at once, blaming only your own code, optimizing input delay only, and testing on a fast machine β€” has a direct corrective practice. Each INP pitfall has a direct correction Pitfall Do this instead setTimeout-only yield prefer scheduler.yield Hydrate everything at once hydrate on visibility or interaction Blame only your own code audit and defer third-party tags Optimize input delay only move heavy work behind a yield Test on a fast machine throttle CPU 4–6x in DevTools
Every INP pitfall on this page resolves to one concrete habit β€” yield well, hydrate lazily, defer third parties, and test throttled.

Frequently Asked Questions

Why does my SPA feel unresponsive right after it loads? Immediately after load the main thread is executing the framework’s hydration β€” attaching event handlers and rebuilding state across the whole component tree. That work runs as long tasks that block the thread, so an early tap cannot be processed until the task finishes. The delay from that interaction to the next paint is exactly what Interaction to Next Paint measures, which is why the page feels frozen precisely when eager users first interact.

What is a long task and how does it hurt INP? A long task is any block of main-thread work that runs for 50 milliseconds or more without yielding. While it runs, the browser cannot handle input or paint, so any interaction that lands during it waits for it to end. Interaction to Next Paint records the worst such delay, so breaking long tasks into smaller chunks that yield to the browser is the core lever for improving the metric.

Does scheduler.yield improve INP more than setTimeout? Yes, in most cases. Yielding with scheduler.yield returns control to the browser so it can process a pending interaction, then resumes your task with continuation priority, so the rest of your work is not sent to the back of the queue. A setTimeout yield also unblocks the thread but reschedules at a lower priority, which can starve the remaining work. Where scheduler.yield is unavailable, fall back to setTimeout.

← Back to Core Web Vitals for Client-Side Apps