How to Fix Interaction to Next Paint (INP)
- Measure INP in production using the
web-vitalsattribution build and CrUX field data. - Profile the worst interactions in Chrome DevTools’ Performance → Interactions track.
- Break long tasks (>50 ms) into smaller chunks using
scheduler.yield()with asetTimeoutfallback. - Defer non-visual work (analytics, telemetry) out of event handlers via
requestIdleCallbackor Web Workers. - Batch DOM reads before writes to eliminate forced synchronous layouts and layout thrashing.
- Reduce DOM size and virtualize long lists to lower style-recalculation and paint costs.
- Audit third-party scripts and apply facades, dynamic imports, or Worker isolation for heavy embeds.
- Verify the fix by confirming the 75th-percentile INP stays at or below 200 ms after the next CrUX data cycle.
Interaction to Next Paint (INP) remains the most punishing Core Web Vital for most websites heading into 2026. This tutorial walks through the concrete techniques needed to diagnose and fix INP issues: breaking long tasks with scheduler.yield(), optimizing event handlers, reducing presentation delay, and taming third-party scripts.
Table of Contents
Why INP Is the Core Web Vital You Can’t Ignore in 2026
Interaction to Next Paint (INP) remains the most punishing Core Web Vital for most websites heading into 2026. According to Chrome UX Report data, roughly 40% of origins on mobile still fail to meet INP thresholds (check the live CrUX dashboard for current numbers), making it a persistent ranking liability that lowers user experience scores. This is not a theoretical problem. Sites with poor INP see higher bounce rates and lower engagement, including shorter session durations and fewer clicks per session.
INP officially replaced First Input Delay (FID) as the responsiveness metric in March 2024. FID was easy to pass. INP is not. It captures the full cost of every interaction on a page, not just the first click. That shift exposed latency that was always there but never measured.
INP captures the full cost of every interaction on a page, not just the first click. That shift exposed latency that was always there but never measured.
This tutorial walks through the concrete techniques needed to diagnose and fix INP issues: breaking long tasks with scheduler.yield(), optimizing event handlers, reducing presentation delay, and taming third-party scripts. Each section includes working code examples targeting a JavaScript-focused tech stack. At the end, a complete optimization checklist provides a reusable reference for ongoing performance work.
What Is Interaction to Next Paint (and Why Did It Replace FID)?
How INP Differs from FID
FID only measured how long the browser waited before processing the first interaction on a page. It timed the gap before the browser began processing the first click, tap, or keypress. It told you nothing about what happened after that, and it ignored every subsequent interaction entirely.
INP takes a fundamentally different approach. It measures the full lifecycle of interactions, covering input delay (the wait before the handler runs), processing time (the handler itself), and presentation delay (the time from handler completion to the next frame being painted). Critically, INP reports the single worst interaction latency for sessions with 50 or fewer interactions; for sessions exceeding 50 interactions, it uses approximately the 98th percentile value to exclude extreme outliers. For pages with complex filtering, form validation, or dynamic UI updates, this makes INP a far harder metric to pass and a far more honest reflection of what users actually experience.
INP Thresholds: Good, Needs Improvement, Poor
Google defines three INP threshold bands:
- Good: 200 milliseconds or less
- Needs Improvement: between 200 and 500 milliseconds
- Poor: greater than 500 milliseconds
These are evaluated at the 75th percentile of page loads in the Chrome UX Report, meaning 75% of a site’s user sessions must report an INP of 200ms or less for the origin to be classified as “good.” This percentile-based approach prevents sites from hiding behind median performance while a quarter of users suffer.
Diagnosing INP Issues: Where to Start
Measuring INP in the Field
The most reliable INP data comes from real users. PageSpeed Insights surfaces origin-level and URL-level INP data from CrUX, collected from opted-in Chrome users over a rolling 28-day window. This is the same data Google uses for ranking signals. Note that CrUX requires a sufficient volume of real-user traffic; low-traffic origins may not have enough data to populate INP scores.
For granular, per-interaction analysis, the web-vitals JavaScript library provides real-user monitoring (RUM) with full attribution. The attribution build identifies the exact element, event type, and handler responsible for poor scores.
Requires web-vitals v3.0.0 or later. Install via npm (npm install web-vitals@^3) or load via CDN using the package’s ESM bundle. The attribution fields available differ between v3 and v4; the snippet below is compatible with both.
import { onINP } from 'web-vitals/attribution';
onINP((metric) => {
const attr = metric.attribution;
console.log({
inp: metric.value,
interactionTarget: attr.interactionTarget,
interactionType: attr.interactionType,
longestEntry: attr.longestInteractionEntry ?? attr.longAnimationFrameEntries ?? null,
});
}, { reportAllChanges: true });
This logs every INP candidate to the console, including which DOM element triggered it and what event type was involved. In production, replace console.log with a beacon to an analytics endpoint.
The longAnimationFrameEntries field is available in web-vitals v4+ and populated only in Chrome 123+ via the Long Animation Frames API. In web-vitals v3, use longestInteractionEntry instead. In browsers that support neither, these fields will be undefined.
Profiling INP in the Lab
Chrome DevTools provides lab-based profiling that complements field data. The Performance panel includes an “Interactions” track that visualizes each discrete user interaction as a horizontal bar, with its total duration broken down into input delay, processing time, and presentation delay. Recent Chrome DevTools versions (approximately Chrome 124+) annotate the specific interaction that would be reported as INP, making it straightforward to identify the worst offender during a profiling session.
The workflow: open DevTools, navigate to the Performance panel, click Record, perform the interactions suspected of causing poor INP, then stop the recording. The Interactions track highlights each one. Clicking an interaction reveals the associated long task in the flame chart. Lighthouse Timespan mode also supports interaction auditing, allowing testers to record a sequence of actions and receive INP-specific diagnostics.
Common Culprits at a Glance
The most frequent sources of poor INP fall into four categories. Heavy event handlers, things like complex form validation, client-side filtering, or large-scale DOM mutations triggered by user actions, remain the single largest contributor. Third-party scripts that block the main thread during or immediately after an interaction follow close behind. Then there is layout thrashing: JavaScript reads and writes to the DOM in alternating sequence, forcing the browser to recalculate layout repeatedly. Why does a large DOM make all of this worse? Because it amplifies rendering cost, making the presentation delay phase of INP disproportionately expensive even for otherwise simple handlers.
Fix 1: Break Up Long Tasks with scheduler.yield()
Why Long Tasks Kill INP
The browser’s main thread is single-threaded. The browser classifies any task longer than 50ms as a “long task,” and long tasks block the browser from processing pending user interactions and painting frames. Suppose a 300ms JavaScript task is running when a user clicks a button. The browser cannot respond until that task completes. The entire 300ms becomes input delay, directly inflating INP.
Yielding back to the main thread between chunks of work gives the browser the opportunity to process pending interactions and render the next frame.
Using the Scheduler API (with Fallback)
The scheduler.yield() API provides a way to explicitly yield to the main thread and schedule its continuation at “user-visible” priority, allowing the browser to process pending rendering tasks before resuming, unlike setTimeout(resolve, 0), which enqueues the continuation at a lower default macrotask priority. As of 2025, scheduler.yield() is supported in Chromium-based browsers (Chrome 115+, Edge 115+) but not in Firefox or Safari; the fallback executes for all non-Chromium traffic.
async function yieldToMain() {
if (globalThis.scheduler?.yield) {
try {
return await scheduler.yield();
} catch {
}
}
return new Promise((resolve) => {
setTimeout(resolve, 0);
});
}
This utility uses scheduler.yield() when available and falls back to setTimeout(resolve, 0) otherwise. The try/catch ensures that if scheduler.yield() rejects (for example, due to future AbortSignal support), the function still resolves via the setTimeout fallback rather than propagating an unhandled rejection. The fallback is imperfect since setTimeout does not guarantee priority re-queuing, but it still breaks the long task and allows the browser to paint.
Here is a before/after comparison demonstrating how to chunk a synchronous loop. The chunked version uses time-based yielding to ensure each chunk stays under the 50ms long-task threshold regardless of how expensive individual items are to render:
function renderListSync(items) {
const container = document.getElementById('container');
if (!container) {
console.error('[renderListSync] #container not found in DOM');
return;
}
items.forEach((item) => {
const el = document.createElement('div');
el.textContent = item.label;
el.className = 'list-item';
container.appendChild(el);
});
}
async function renderListChunked(items) {
const container = document.getElementById('container');
if (!container) {
console.error('[renderListChunked] #container not found in DOM');
return;
}
const CHUNK_BUDGET_MS = 40;
let chunkStart = performance.now();
for (let i = 0; i < items.length; i++) {
const el = document.createElement('div');
el.textContent = items[i].label;
el.className = 'list-item';
container.appendChild(el);
if (performance.now() - chunkStart >= CHUNK_BUDGET_MS) {
await yieldToMain();
chunkStart = performance.now();
}
}
}
The synchronous version processes all 500 items in a single task. The chunked version yields whenever the elapsed time within the current chunk approaches 40ms, ensuring each chunk stays comfortably under the 50ms long-task threshold regardless of per-item rendering cost. Each yield point gives the browser a window to handle pending interactions and paint.
Fix 2: Optimize Event Handlers and Reduce Processing Time
Debounce for Inputs, Not Clicks
Debouncing delays execution until rapid-fire events stop, making it effective for search inputs where processing should only happen after the user pauses typing. Throttling limits execution to a fixed interval, making it suitable for scroll or resize-driven UI updates where some responsiveness during the event stream is necessary.
A key caveat: debouncing does not help click-driven INP. Clicks are discrete events, not rapid-fire streams. If a click handler is slow, debouncing it changes nothing. The optimization for click handlers is reducing the work inside them, not delaying when they fire.
Offload Non-Visual Work with requestIdleCallback and Web Workers
Event handlers often contain work that is not visually relevant to the interaction, such as analytics logging, telemetry computation, or state synchronization with external services. Moving this work out of the handler’s synchronous path lets the browser paint immediately after the visual update completes.
requestIdleCallback is not supported in Safari. For cross-browser compatibility, use a fallback. Note: use globalThis instead of window to avoid ReferenceError in SSR, test environments, or Web Workers:
const scheduleIdle = globalThis.requestIdleCallback ?? ((fn) => setTimeout(fn, 0));
button.addEventListener('click', () => {
resultsPanel.classList.add('active');
resultsPanel.textContent = computeVisibleResult();
const analyticsSnapshot = {
resultText: resultsPanel.textContent,
timestamp: Date.now(),
};
scheduleIdle(() => {
const payload = buildAnalyticsPayload(analyticsSnapshot);
const body = JSON.stringify(payload);
const sent = navigator.sendBeacon('/analytics', body);
if (!sent) {
fetch('/analytics', {
method: 'POST',
body,
keepalive: true,
headers: { 'Content-Type': 'application/json' },
}).catch((err) => {
console.error('[analytics] fallback fetch failed:', err);
});
}
});
});
The visual update happens synchronously inside the click handler. The analytics data is snapshotted from the DOM immediately (while it is stable), and the actual beacon is deferred to an idle period. This avoids reading from a potentially detached or mutated DOM node inside the idle callback. The sendBeacon return value is checked: if it returns false (payload too large or queue full), a fetch with keepalive: true is used as a fallback. For interactions that may precede page unload (e.g., final button clicks), send the beacon synchronously or use a visibilitychange listener as an additional send trigger to avoid data loss. For heavier computation, Web Workers provide true off-main-thread execution.
Event Delegation Reduces Listener Count
Attaching individual listeners to every interactive element in a large list or table creates memory overhead and complicates handler management. Event delegation attaches a single listener to a parent container and uses event.target.closest() to identify which child triggered the event.
const ALLOWED_ACTIONS = new Set(['delete', 'edit', 'archive']);
document.getElementById('button-list').addEventListener('click', (event) => {
const button = event.target.closest('[data-action]');
if (!button || button === event.currentTarget) return;
if (button.disabled || button.getAttribute('aria-disabled') === 'true') return;
const action = button.dataset.action;
if (!ALLOWED_ACTIONS.has(action)) return;
if (action === 'delete') removeItem(button);
if (action === 'edit') openEditor(button);
if (action === 'archive') archiveItem(button);
});
One listener handles all actions for all buttons within #button-list. Adding or removing buttons dynamically requires no listener management. The disabled and aria-disabled guards prevent actions from firing on inactive buttons, and the allowlist ensures only recognized actions are dispatched.
Fix 3: Minimize Presentation Delay (Input to Next Paint)
Avoid Forced Synchronous Layouts
Layout thrashing occurs when JavaScript alternates between reading layout properties and writing to the DOM, forcing the browser to recalculate layout on every read. This is the most common rendering bottleneck in the presentation delay phase of INP.
items.forEach((item) => {
item.style.height = container.offsetHeight + 'px';
item.style.width = '100%';
});
const height = container.offsetHeight;
items.forEach((item) => {
item.style.height = height + 'px';
item.style.width = '100%';
});
The “before” pattern forces the browser to compute layout on every iteration because each offsetHeight read requires up-to-date layout information after the preceding write. The “after” pattern reads once, then writes in batch, triggering only a single layout recalculation.
Layout thrashing occurs when JavaScript alternates between reading layout properties and writing to the DOM, forcing the browser to recalculate layout on every read. This is the most common rendering bottleneck in the presentation delay phase of INP.
Reduce DOM Size and Complexity
Large DOMs increase the cost of style recalculation, layout, and paint. Targeting fewer than 1,400 DOM elements where possible, a Lighthouse audit warning threshold, not a hard INP specification limit, can help keep rendering overhead manageable. For long lists, virtualization libraries such as react-window render only the visible items plus a small buffer (a native browser virtualization API has been proposed but is not available in any production browser). The CSS content-visibility: auto property instructs the browser to skip rendering work for off-screen sections entirely, reducing both layout and paint cost without JavaScript. Note: content-visibility: auto reports off-screen elements as zero-height until rendered; test scroll restoration and in-page search behavior after applying it.
Use CSS will-change and Compositor Layers Carefully
Promoting animated elements to their own compositor layer using will-change: transform or will-change: opacity moves their paint work off the main thread, preventing animations from contributing to INP’s presentation delay. However, overuse increases GPU memory consumption significantly. Every promoted layer consumes memory proportional to its pixel area. Apply will-change only to elements actively animating and remove it when the animation completes.
Fix 4: Tame Third-Party Scripts
Audit Third-Party Impact
The DevTools Performance panel includes a “Third-party” filter that isolates main-thread activity attributable to external scripts. The community-maintained “Third Party Web” dataset (thirdpartyweb.today) catalogs known third-party domains and their typical performance impact, providing a reference for identifying which tags are most likely to interfere with INP.
Loading Strategies
The async attribute loads scripts in parallel but executes as soon as the download completes, at an unpredictable time relative to user interactions, which can introduce main-thread contention during active sessions. The defer attribute delays execution until after HTML parsing, which is safer but still runs on the main thread. Dynamic import() triggered after a user interaction loads a script only when needed, eliminating any upfront main-thread cost.
The facade pattern applies this principle to heavy embeds: chat widgets, video players, and social media embeds display a static placeholder image or button. The actual embed loads only when the user clicks the placeholder. For analytics tags that must load early, Partytown or Web Worker isolation moves their execution entirely off the main thread.
Complete INP Optimization Checklist
- Measure INP with the
web-vitalsv3+ attribution build in production RUM. - Profile worst interactions in the DevTools Interactions track.
- Identify and break long tasks exceeding 50ms using
scheduler.yield()or chunking. - Move non-visual work out of event handlers using
requestIdleCallback(with Safari fallback) or Web Workers. - Replace per-element listeners with event delegation where you have repeated sibling controls.
- Eliminate layout thrashing by batching DOM reads and writes.
- Reduce DOM size below 1,400 elements (Lighthouse warning threshold); virtualize long lists with
react-windowor similar. - Audit and defer or facade third-party scripts.
- Apply
content-visibility: autofor below-fold content (test scroll restoration and in-page search). - Re-measure after the 28-day CrUX data cycle and confirm the 75th-percentile INP sits at 200ms or less.
INP Is a Moving Target: Stay Proactive
INP rewards holistic responsiveness across every interaction on a page, not just first-click speed. The techniques covered here, yielding to the main thread, deferring non-visual work, batching DOM operations, and controlling third-party execution, address the metric’s three phases directly.
INP scores shift as code, content, and third-party dependencies change; adding a single new chat widget script, for example, can add 80-150ms of main-thread blocking to every page it loads on.
Continuous monitoring through RUM dashboards and CrUX API alerts is essential. INP scores shift as code, content, and third-party dependencies change; adding a single new chat widget script, for example, can add 80-150ms of main-thread blocking to every page it loads on. Google has tightened Web Vitals thresholds before (Cumulative Layout Shift scoring methodology changed in 2021), and Google may tighten the 200ms “good” boundary in a future update. Pin the checklist above to your team’s performance runbook. Keeping INP under control means building responsiveness monitoring into your development cycle and revisiting it with every major dependency or feature change.

