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.
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.
// 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-vitalsattribution 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.
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; preferscheduler.yieldand usesetTimeoutonly 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.
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.
Related
- Core Web Vitals for Client-Side Apps β how INP fits alongside LCP and CLS in a SPA.
- Deferring Non-Critical JavaScript for Faster Indexing β get non-essential scripts off the interaction window.
- Reducing LCP for Client-Rendered Hero Content β the paint metric that heavy hydration also delays.
β Back to Core Web Vitals for Client-Side Apps