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:
- Input delay — time from user gesture to when the browser begins running event handlers. Caused by other JavaScript tasks blocking the main thread.
- Processing time — time spent running your event handlers (
click,keydown,input, etc.). - 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
- Open DevTools → Performance panel.
- Check "Web Vitals" in the top bar.
- Record while clicking through the page.
- Look for INP entries in the "Timings" lane. Click one to jump to the long task.
- 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
inputandkeydownhandlers 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.