Interaction to Next Paint (INP): What Developers Need to Know and Fix

Interaction to Next Paint (INP) replaced First Input Delay (FID) as a Core Web Vital in March 2024. Where FID only measured the delay before the browser started handling the first interaction, INP measures the full latency of every interaction on a page — from user input to the next visual update. A slow click handler that blocks the main thread for 300 ms will generate a poor INP score even if FID was perfect.

The threshold: good is 200 ms or below, poor is above 500 ms. Measured at the 75th percentile across all interactions in a session.

The Three Phases of an Interaction

Every interaction has three components that add up to the INP value:

  1. Input delay — time from user gesture to when the browser begins running event handlers. Caused by other JavaScript tasks blocking the main thread.
  2. Processing time — time spent running your event handlers (click, keydown, input, etc.).
  3. Presentation delay — time for the browser to finish layout, paint, and composite after your handlers complete.
// Measuring INP with web-vitals
import { onINP } from 'https://unpkg.com/web-vitals@4/dist/web-vitals.attribution.js';

onINP((metric) => {
  console.log('INP:', metric.value, 'ms');

  // Attribution tells you WHICH interaction caused the poor score
  const { interactionTarget, interactionType, inputDelay, processingDuration, presentationDelay } = metric.attribution;

  console.log({
    element:      interactionTarget,  // CSS selector of the element
    type:         interactionType,    // "pointer", "keyboard", "drag"
    inputDelay,
    processingDuration,
    presentationDelay,
  });
});

Diagnosing INP in Chrome DevTools

  1. Open DevTools → Performance panel.
  2. Check "Web Vitals" in the top bar.
  3. Record while clicking through the page.
  4. Look for INP entries in the "Timings" lane. Click one to jump to the long task.
  5. In the flame chart, look for tasks longer than 50 ms (marked with a red corner).

Alternatively, use the Performance Insights panel (DevTools sidebar), which categorises INP issues and suggests specific element selectors.

Fix 1: Break Up Long Tasks

The browser cannot paint a frame while JavaScript is running. Any task longer than 50 ms is a "long task" and will cause input delay for any interaction that occurs during it.

// BAD: one 500ms synchronous task blocks input and paint
function processBigList(items) {
  items.forEach(item => heavyOperation(item));
  renderResults();
}

// GOOD: yield to the browser between chunks using scheduler.yield()
async function processBigList(items) {
  const CHUNK = 50;
  for (let i = 0; i < items.length; i += CHUNK) {
    const chunk = items.slice(i, i + CHUNK);
    chunk.forEach(item => heavyOperation(item));

    // Yield to the browser — allows paint and input handling
    await scheduler.yield();
  }
  renderResults();
}

scheduler.yield() is available in Chrome 115+ and is the preferred method over setTimeout(fn, 0) because it has higher priority than setTimeout macrotasks, returning control to the browser more efficiently.

Polyfill for browsers without scheduler.yield():

async function yieldToMain() {
  if ('scheduler' in self && 'yield' in scheduler) {
    return scheduler.yield();
  }
  // Fallback: MessageChannel is faster than setTimeout(0)
  return new Promise(resolve => {
    const { port1, port2 } = new MessageChannel();
    port1.onmessage = resolve;
    port2.postMessage(null);
  });
}

Fix 2: Reduce Event Handler Work

Event handlers run synchronously before the browser can paint. Any work inside a click or keydown handler directly extends processing time:

// BAD: synchronous DOM query and layout-forcing read inside click handler
button.addEventListener('click', (e) => {
  const height = expensiveContainer.offsetHeight; // forces layout
  doHeavyCalculation(height);
  updateMultipleDOMNodes();
});

// GOOD: defer heavy work, only do essential DOM update in the handler
button.addEventListener('click', async (e) => {
  // 1. Make the visible change immediately (fast paint)
  button.classList.add('loading');
  button.textContent = 'Processing…';

  // 2. Yield, then do heavy work
  await yieldToMain();
  const result = await doHeavyCalculation();
  updateMultipleDOMNodes(result);
  button.classList.remove('loading');
  button.textContent = 'Done';
});

Fix 3: Debounce and Throttle Input Events

input, keydown, and scroll fire on every event — dozens of times per second. Running expensive operations on each event is a common INP killer:

// Search field: debounce the API call
const searchInput = document.getElementById('search');
let debounceTimer;

searchInput.addEventListener('input', (e) => {
  // Immediate visual feedback — update the input value immediately
  updateCounter(e.target.value.length);

  // Defer the expensive search until typing pauses
  clearTimeout(debounceTimer);
  debounceTimer = setTimeout(() => {
    fetchSearchResults(e.target.value);
  }, 250);
});

Fix 4: Avoid Layout Thrashing

Reading layout properties (offsetHeight, getBoundingClientRect) after writing to the DOM forces synchronous layout recalculation — a classic pattern that inflates processing time and presentation delay:

// BAD: alternating read/write forces layout on every iteration
elements.forEach(el => {
  const height = el.offsetHeight;     // forces layout
  el.style.height = height + 10 + 'px'; // write
  const width = el.offsetWidth;       // forces layout AGAIN
  el.style.width = width + 10 + 'px'; // write
});

// GOOD: batch reads, then batch writes
const dimensions = elements.map(el => ({
  height: el.offsetHeight,   // all reads together
  width:  el.offsetWidth,
}));

elements.forEach((el, i) => {
  el.style.height = dimensions[i].height + 10 + 'px';  // all writes together
  el.style.width  = dimensions[i].width  + 10 + 'px';
});

Fix 5: Move Work Off the Main Thread

For truly CPU-intensive tasks (image processing, data parsing, heavy filtering), move work to a Web Worker:

// main.js
const worker = new Worker('/js/data-worker.js');

button.addEventListener('click', async () => {
  button.disabled = true;

  // Send data to worker — does not block main thread
  worker.postMessage({ type: 'process', data: largeDataset });

  // Receive result when ready
  const result = await new Promise(resolve => {
    worker.onmessage = (e) => resolve(e.data);
  });

  updateUI(result);
  button.disabled = false;
});
// data-worker.js
self.onmessage = (e) => {
  if (e.data.type === 'process') {
    const result = heavyProcessing(e.data.data);
    self.postMessage(result);
  }
};

Fix 6: Reduce DOM Size

Large DOMs (10,000+ nodes) increase layout and paint time, directly inflating presentation delay. Google recommends:

  • Fewer than 1,500 total DOM nodes.
  • Maximum depth of 32 nodes.
  • Fewer than 60 children for any parent node.

For large lists, use virtual scrolling:

<!-- Use a virtual list library rather than rendering all items -->
<script type="module">
  import { VirtualScroller } from 'https://unpkg.com/virtual-scroller@1.0.0/index.js';

  const scroller = new VirtualScroller({
    items: myLargeArray,       // All data, not all DOM nodes
    renderItem: (item) => {
      const el = document.createElement('div');
      el.className = 'list-item';
      el.textContent = item.label;
      return el;
    },
  });

  document.getElementById('list').appendChild(scroller);
</script>

Fix 7: Drupal and PHP-Rendered Pages

Server-rendered PHP pages typically have lower INP than SPAs because there is less JavaScript. The main INP risks in a Drupal context are:

  • Third-party scripts (analytics, tag managers, chat widgets) that add long tasks. Defer or load asynchronously.
  • jQuery-heavy contrib modules with synchronous event handlers. Profile with DevTools to identify offenders.
  • CKEditor 5 on edit pages — large editor initialization tasks. Defer initialisation until the field is focused.
  • Views AJAX — ensure the AJAX response handler does not trigger layout thrashing by reading layout after injecting HTML.
// Defer non-critical third-party scripts
<script>
// Load analytics after first interaction, not on page load
function loadAnalytics() {
  const script = document.createElement('script');
  script.src = 'https://analytics.example.com/tag.js';
  script.async = true;
  document.head.appendChild(script);
}

// Wait for first user interaction
['click', 'keydown', 'touchstart'].forEach(event =>
  document.addEventListener(event, loadAnalytics, { once: true })
);
</script>

Using Chrome User Experience Report (CrUX) for Real-World INP

# Check your site's real INP from CrUX via PageSpeed Insights API
curl "https://www.googleapis.com/pagespeedonline/v5/runPagespeed?url=https://example.com&strategy=mobile&key=YOUR_API_KEY" \
  | jq '.loadingExperience.metrics.INTERACTION_TO_NEXT_PAINT'

Summary Checklist

  • Measure INP with the web-vitals library; use attribution to find the specific element and interaction type.
  • Profile with DevTools Performance panel; identify long tasks (>50 ms) during interactions.
  • Break long synchronous tasks into chunks using scheduler.yield().
  • Keep event handlers fast: do only the minimum visible update synchronously, defer the rest.
  • Debounce input and keydown handlers that trigger expensive operations.
  • Batch DOM reads before DOM writes to prevent layout thrashing.
  • Move CPU-heavy work to Web Workers.
  • Keep DOM size below 1,500 nodes; use virtual scrolling for long lists.
  • Defer third-party scripts until after first user interaction.
  • Check real-world INP via CrUX in Search Console or the PageSpeed Insights API.

Add new comment

Restricted HTML

  • Allowed HTML tags: <a href hreflang> <em> <strong> <cite> <blockquote cite> <code> <ul type> <ol start type> <li> <dl> <dt> <dd> <h2 id> <h3 id> <h4 id> <h5 id> <h6 id>
  • Lines and paragraphs break automatically.
  • Web page addresses and email addresses turn into links automatically.
Please share this article on your favorite website or platform.