INP Failures — Long Tasks Block Input Handling
Improve Interaction to Next Paint by finding long tasks that delay event handlers, use scheduler.yield and code splitting, and break up render-blocking work.
Symptoms
- Chrome UX Report INP lands in the poor band while other Core Web Vitals pass
- Clicking a filter or typing in search feels delayed; the next paint happens hundreds of milliseconds later
- Performance recordings show input events queued behind a long task during interactions
Possible causes
- A click handler synchronously processes a large dataset or renders a large component tree
- Hydration or framework re-render runs inside the input handler, blocking the frame
- Monolithic bundles parse and execute on the main thread, and interactions land during that work
- Expensive synchronous work (sorting, diffing, string building) happens before the first paint after input
Diagnosis steps
Record an interaction
DevTools → Performance → Record → click the sluggish control → stop; inspect the input event trackInterpretation
The gap between the input event and the next paint, and which task fills it, is the entire diagnosis.
Find long tasks programmatically
new PerformanceObserver((l) => l.getEntries().forEach(e => console.log(e.name, e.duration, e.attribution))).observe({ entryTypes: ['longtask'] })Interpretation
Long tasks above 50ms that overlap the interaction window name the blocking work and its source (script URL).
Trace the handler cost breakdown
DevTools → Performance → click the task → Bottom-Up viewInterpretation
The self-time ranking inside the task separates framework re-render from your own computation, telling you which side to fix.
Root cause
INP measures the latency from input to the next paint. When the main thread is executing a long task at input time — or the handler itself performs heavy synchronous work before yielding — the input waits and the paint waits behind it, producing poor interaction latency.
Fix
Break handlers into immediate feedback plus deferred heavy work (scheduler.yield, isInputPending-aware chunking), code-split large bundles so parse work does not coincide with interactions, virtualize large lists, and memoize render paths so inputs trigger minimal re-renders.
Risk note
Deferring all handler work delays logic correctness (double submits, races). Yield for paint, then run logic, with explicit state guards.
Verify the fix
Interactions in a Performance recording show handler start within the first frame, heavy work yielding across frames, and INP field data moving into the good band.
Symptom walkthrough
The Performance recording shows the wait concretely: the input event lands, then a 180ms task runs before the first frame after it. The observer gives you the inventory:
new PerformanceObserver((l) =>
l.getEntries().forEach((e) =>
console.log(e.name, Math.round(e.duration),
e.attribution?.[0]?.containerSrc))
).observe({ entryTypes: ['longtask'] });
// longtask 214 app.js
A 214ms task from app.js overlapping the interaction is the blocker. Inside the task, the Bottom-Up view separates framework re-render time from your own loop — the fix targets whichever dominates.
Fix in practice
Split the handler into instant feedback and deferred logic:
button.addEventListener('click', async () => {
setLoading(true); // paint happens here
const yieldToBrowser =
scheduler.yield ? scheduler.yield() : new Promise(r => setTimeout(r));
await yieldToBrowser; // input, paint, then heavy work
await processRows(rows); // chunked internally
setLoading(false);
});
Chunk long computations so each slice stays under a frame:
async function processRows(rows) {
for (let i = 0; i < rows.length; i += 5000) {
work(rows.slice(i, i + 5000));
await (scheduler.yield?.() ?? new Promise(r => setTimeout(r)));
}
}
And stop the parse-time collisions: dynamic-import the report builder so its megabyte of code never executes during the moments users click filters.