LEARN · DEBUGGING GUIDE

Debugging High Interaction to Next Paint (INP) Scores

High INP scores mean users feel your app is unresponsive. This guide shows you how to pinpoint the exact interaction that's slow and fix it without guesswork.

IntermediatePerformance9 min read

What this usually means

High INP is rarely caused by a single slow line of code. It's typically a cumulative effect: a user interaction triggers a cascade of event handlers (often attached to parent elements), each doing unnecessary work. This is compounded by a congested main thread — long-running tasks from third-party scripts, excessive layout thrashing, or unoptimized rendering. The browser cannot paint the next frame until all JavaScript and layout work for that interaction completes. The key insight: INP measures the *entire* journey from user gesture to the next visual update. That includes input delay (waiting for other tasks to finish), processing time (event handlers), and presentation delay (layout, paint). Most debugging guides focus only on processing time, but input delay often dominates — meaning your code might be fine, but the browser is busy doing something else when the user clicks.

( 01 )Fast diagnosis

The first ten minutes — establish facts before touching code.

  • 11. Open Chrome DevTools > Performance panel, record an interaction that feels slow. Look for long 'Event: click' or 'pointerdown' entries in the flame graph.
  • 22. Check the 'Timings' track for 'First Input Delay' (FID) vs 'Interaction to Next Paint' — FID is just the input delay part; subtract FID from INP to get processing+presentation time.
  • 33. Run `performance.measureUserAgentSpecificMemory()` in the console to check memory pressure — high memory usage often correlates with slower interactions.
  • 44. On the page, add `performance.mark('start')` at the start of your click handler and measure with `performance.measure('click-duration', 'start')` at the end. Output to console.
  • 55. Check your RUM provider (e.g., Web Vitals library) for the `interactionId` property in the INP attribution object — this tells you which element the interaction happened on.
  • 66. Use `chrome://tracing` with the 'disabled-by-default-v8.cpu_profiler' category to capture low-level V8 function timings for the duration of the interaction.
( 02 )Where to look

The specific files, logs, configs, and dashboards that usually own this bug.

  • searchChrome DevTools Performance panel — specifically the 'Main' thread flame graph and 'Timings' track
  • searchweb-vitals JavaScript library's `onINP()` callback — logs `attribution` object containing `interactionTarget`, `interactionType`, and `interactionTime`
  • searchYour application's event listener registrations — check if listeners are attached to `document` or `window` (event delegation) causing unnecessary bubbling
  • searchThird-party script initialization — often `DOMContentLoaded` or `load` event handlers from analytics or chat widgets
  • searchLayout thrashing traces in Performance panel — look for 'Layout' entries that are forced by reading `offsetHeight` after DOM mutations
  • searchServer-side rendering (SSR) hydration logs — if using React, Next.js, or Nuxt, hydration mismatches cause long synchronous re-renders
  • searchMemory heap snapshots before and after interaction — compare to detect excessive object allocation during click handlers
( 03 )Common root causes

Practical causes, not theory. These are the things you will actually find.

  • warningToo many event listeners on ancestor elements (event delegation) that are called even when the interaction is on a child with no handler — each listener adds overhead.
  • warningThird-party scripts that run long synchronous tasks (e.g., parsing JSON, building DOM) triggered by the same user event (e.g., analytics beacon on click).
  • warningExcessive layout reflows caused by reading layout properties (e.g., `offsetWidth`) after writing to the DOM in the same event loop tick.
  • warningLong-running synchronous JavaScript inside event handlers — loops, heavy computations, or network requests made synchronously (e.g., `XMLHttpRequest` with `async: false`).
  • warningMonolithic microfrontend or component architectures where a single interaction triggers re-rendering of large subtrees (e.g., React `setState` at the root level).
  • warningAnimation frames or `requestAnimationFrame` callbacks that are queued but not executed until after the interaction — they block the next paint.
  • warningHigh main thread congestion from background tasks like service worker sync, background fetch, or IndexedDB operations.
( 04 )Fix patterns

Concrete fix directions. Pick the one that matches your root cause.

  • buildThrottle event handlers with `requestAnimationFrame` or a debounce to coalesce multiple rapid events into one batch.
  • buildMove heavy computation off the main thread using Web Workers — especially for data processing in response to user input (e.g., search suggestions).
  • buildUse `passive: true` event listeners for scroll and touch events to avoid blocking the browser's scrolling optimizations.
  • buildMinimize event delegation depth: attach listeners to the closest common ancestor, not `document`. Use `event.stopPropagation()` wisely (but don't break other handlers).
  • buildBatch DOM reads and writes using libraries like FastDOM or manual microtask scheduling to prevent layout thrashing.
  • buildLazy-load third-party scripts and defer their initialization until after the user interaction is complete (e.g., use `requestIdleCallback`).
  • buildProfile and optimize React component re-renders: use `React.memo`, `useMemo`, `useCallback`, and consider splitting state to avoid unnecessary re-renders on interaction.
( 05 )How to verify

A fix you cannot prove is a guess. Close the loop.

  • verified1. Re-run the interaction in Chrome DevTools Performance panel and confirm the 'Event' duration is below 200ms.
  • verified2. Use the web-vitals library to log the INP value and its attribution in the console — target the same element and verify it's now green.
  • verified3. Run Lighthouse simulation (or lab test) with network throttling (e.g., Fast 3G) and CPU slowdown 4x to simulate mid-range devices.
  • verified4. Deploy the fix to a canary/percentage of users and compare p75 INP from RUM data before and after (allow 1-2 days of data).
  • verified5. Check the 'Long Tasks' API — after the fix, no long task (>50ms) should start within 500ms of the user interaction.
  • verified6. Use `performance.measureUserAgentSpecificMemory()` before and after interaction to ensure memory allocation didn't spike.
( 06 )Mistakes to avoid

Things that make this bug worse or harder to find.

  • warningAdding `setTimeout` with a delay of 0 to fix INP — this just defers the work to a later frame, potentially causing other issues and not truly reducing INP.
  • warningBlindly removing event listeners without understanding the component logic — you might break functionality.
  • warningAssuming INP is purely a frontend problem — sometimes slow APIs or large JSON responses cause the main thread to wait (stall) while parsing.
  • warningOver-optimizing the handler code when the real issue is input delay from background tasks (check the 'Input delay' part in DevTools).
  • warningUsing `performance.mark()` and `performance.measure()` without clearing marks — results in inaccurate measurements and memory leaks.
  • warningNot testing on real mobile devices — desktop DevTools CPU throttling doesn't perfectly replicate mobile main thread characteristics.
( 07 )War story

The Case of the Sluggish Search Autocomplete

Senior Frontend EngineerReact 18, Next.js 13, Elasticsearch, Web Vitals library, Vercel Analytics

Timeline

  1. 09:15Alert: p75 INP on the search results page jumped from 180ms to 350ms in the last hour.
  2. 09:20Check RUM data: the high INP is correlated with the 'search-input' element (target: #search-box).
  3. 09:25Open DevTools Performance, record a keystroke in the search box. See a long 'keydown' event (280ms).
  4. 09:27In the flame graph, the 'keydown' is followed by multiple 'Function Call' entries: autocomplete fetch, state update, and a re-render of the entire search results component (large list).
  5. 09:30Notice that the autocomplete fetch is a synchronous XHR (async:false) — a legacy piece of code. That's blocking the main thread.
  6. 09:35Also see a 'Layout' forced reflow event — the component reads `scrollTop` after setting new search results, causing layout thrash.
  7. 09:40Fix: Replace synchronous XHR with async fetch. Batch DOM reads and writes. Debounce the input handler by 300ms.
  8. 09:45Deploy fix to staging, re-run performance test: keydown event now 40ms. INP lab test shows 120ms.
  9. 10:00Push to production canary (10% traffic). Monitor RUM: p75 INP back to 190ms.

The alert came in at 9:15 — p75 INP jumped from 180ms to 350ms on the search results page. I immediately checked the RUM data and saw that the slow interactions were on the search input element. That's the autocomplete feature. I knew I had to reproduce it locally.

I opened Chrome DevTools Performance and recorded a few keystrokes. The flame graph showed a massive 'keydown' event taking nearly 300ms. Drilling down, I saw a synchronous XHR call to fetch autocomplete suggestions. That was a legacy piece of code I had overlooked during the migration to Next.js. It was blocking the main thread completely. Additionally, after the fetch, the component was reading `scrollTop` immediately after appending new results, causing a forced layout – that added another 50ms.

I replaced the synchronous XHR with an async `fetch` call, and debounced the input handler by 300ms using a simple `setTimeout` pattern. I also moved the DOM reads to before writes (using a requestAnimationFrame batch). After deploying to staging, the keydown event went from 280ms to 40ms. We pushed to canary and the RUM data confirmed the fix: p75 INP dropped back to 190ms. Lesson: always check for synchronous network calls in event handlers, and batch DOM operations.

Root cause

A legacy synchronous XHR call in the autocomplete event handler, combined with layout thrashing from interleaved DOM reads and writes.

The fix

Replaced synchronous XHR with async fetch, added input debouncing (300ms), and batched DOM reads before writes using requestAnimationFrame.

The lesson

Even a single synchronous network request in an event handler can dominate INP. Always use async APIs and debounce frequent interactions.

( 08 )Understanding the INP Attribution Object

The web-vitals library's `onINP()` callback provides an `attribution` object with critical details. The `interactionTarget` property returns the CSS selector of the element the user interacted with (e.g., '#submit-btn'). The `interactionType` tells you if it was a 'click', 'keydown', 'pointerdown', etc. The `interactionTime` is the timestamp when the interaction started. Use these to filter RUM data and isolate problematic elements.

Additionally, the `inputDelay` property (in ms) represents how long the browser waited before starting to process the event handler because the main thread was busy. If this is high (>100ms), focus on reducing background tasks or deferring third-party scripts. The `processingDuration` and `presentationDelay` help you pinpoint whether the issue is in your handler code or in the rendering phase.

( 09 )Profiling Event Handlers with Chrome DevTools

The Performance panel is your best friend. Record a short session (5-10 seconds) while performing the slow interaction. Look for the 'Event' entry with the type (e.g., 'click') and expand it. The 'Summary' tab shows the total time. The 'Call Tree' tab shows the functions that consumed the most time. Pay special attention to 'Layout' and 'Paint' entries — they indicate forced reflows.

Enable the 'Web Vitals' track in DevTools (three-dot menu > More tools > Web Vitals). This shows live metrics including INP. You can click on an interaction to see its breakdown. Also, use the 'Long Tasks' track to see tasks >50ms that block the main thread — they often precede the interaction and cause input delay.

( 10 )Reducing Input Delay from Third-Party Scripts

Third-party scripts (analytics, ads, chat widgets) are a common source of input delay because they register event listeners on the document or window. Even if they don't handle the interaction, they still get called during the bubbling phase. Use Chrome DevTools' 'Coverage' tab to see which third-party scripts have code that runs during idle time. Consider deferring their initialization using `requestIdleCallback` or loading them after user interaction (e.g., after first click).

Another tactic: use `performance.measureUserAgentSpecificMemory()` to track memory usage of iframes (common for third-party widgets). High memory in an iframe can slow down your main thread due to cross-origin overhead. If possible, lazy-load iframes only when needed.

( 11 )Layout Thrashing and Its Impact on INP

Layout thrashing happens when you read a layout property (like `offsetHeight`, `scrollTop`) after modifying the DOM. The browser must force a synchronous layout to return the correct value. In event handlers, this can double the processing time. To detect it, look for 'Layout' entries that appear immediately after DOM mutations in the Performance timeline. The 'Layout' entry's duration is the time spent recalculating styles and layout.

Fix: Use a library like FastDOM, or manually batch all DOM writes together in a requestAnimationFrame callback, and schedule reads in the next microtask. Alternatively, avoid reading layout properties in event handlers altogether — cache the values beforehand or use `getBoundingClientRect()` sparingly.

( 12 )Advanced: Using `chrome://tracing` for V8-Level Insights

When the Performance panel isn't enough, use the Chrome Tracing tool (`chrome://tracing`). Enable the 'disabled-by-default-v8.cpu_profiler' category and record a trace. This shows every V8 function call with its timestamp and duration at the microsecond level. You can filter by 'v8.execute' or 'v8.compile' to see if JavaScript compilation is happening during the interaction (a common cause of jank).

Also look for 'TaskQueueManager' and 'ThreadController' events to understand how the main thread scheduler prioritizes tasks. If you see a long idle period before the interaction, that's input delay. The tracing data can be overwhelming, but focusing on the 500ms window around the interaction will reveal the exact sequence of events.

Frequently asked questions

What is the difference between FID and INP?

First Input Delay (FID) measures the delay from the first user interaction to the time the browser can start processing the event handler. INP measures the entire duration from the interaction to the next visual update, including processing and presentation. INP is a more complete metric because it captures the full user-perceived latency, not just the input delay.

Can a single slow interaction affect other interactions?

Yes. If an event handler takes a long time (e.g., 500ms), the main thread is blocked during that time. Subsequent interactions that occur during that period will experience high input delay because they have to wait for the current task to finish. This can cause multiple interactions to have poor INP even if they are individually fast.

Why does Lighthouse report a different INP than my RUM data?

Lighthouse runs a lab test on a simulated device and network, so it captures a single snapshot. RUM data reflects real user conditions — different devices, network speeds, and usage patterns. The RUM p75 or p95 INP is more representative of your actual user experience. Lighthouse is useful for catching regressions, but always verify with RUM.

Should I use passive event listeners for all events?

Only for events that don't need to call `preventDefault()`, such as `touchstart`, `touchmove`, `scroll`, and `wheel`. For click events, you may need to prevent default behavior (e.g., link navigation), so passive listeners would be inappropriate. Use passive listeners where possible to allow the browser to optimize scrolling and touch interactions.

How can I measure INP in development before deployment?

Use the web-vitals library in your development environment. Call `onINP(console.log)` to see INP values in the console. Also, use Chrome DevTools' Performance panel with the 'Web Vitals' track enabled. Simulate mobile conditions by enabling CPU throttling (4x slowdown) and network throttling (Fast 3G) in DevTools to catch issues early.