Interaction latency

Measure the delay between a tap and the screen changing, and fix it by giving the main thread somewhere to breathe.

05of 16
≈ 13Min read
5Parts

Interaction to Next Paint

INP replaced First Input Delay in March 2024, and it is a much harder test. FID measured only the first interaction, and only the delay before the handler started — a page could score well and still be miserable. INP looks at every click, tap and key press for the whole visit, and measures all the way to the next frame on screen.

It has three parts: input delay (the main thread was busy when you tapped), processing time (your handler ran), and presentation delay (the browser laid out and painted the result). Good is 200 ms; over 500 ms is poor.

Which interaction gets reported

Not the average — the bad one. Under 50 interactions the reported INP is the worst; above that, the browser forgives one interaction per 50, so at 100 interactions it reports the third-worst. The intent is to ignore a single freak while still refusing to hide a genuinely slow control.

That means one 600 ms interaction on a page with fourteen fast ones is your score. Averages lie about responsiveness; this metric was designed not to.

The cause is almost always a long task

JavaScript is single-threaded. While a task is running the browser cannot respond to anything — a tap is queued, not handled. If hydration is a 900 ms block of work, a tap at 300 ms waits 600 ms before your handler even begins, and no amount of optimising the handler helps.

Total Blocking Time counts exactly this: for every task over 50 ms, the part above 50 ms. It is measured in the lab, correlates well with field INP, and it is the number to watch in CI.

Yield, and the delay disappears

scheduler.yield() returns to the browser and then continues at the front of the queue, so yielding does not send your work to the back — which was the reason people avoided setTimeout(0). For work that is not urgent at all, scheduler.postTask(fn, { priority: "background" }) or requestIdleCallback.

the same work, made interruptible
/* one long task: nothing can happen for the whole loop */
for (const row of rows) render(row);

/* many short ones: the browser can answer a tap between any two */
async function renderAll(rows) {
  for (const row of rows) {
    render(row);
    if (needsYield()) await yieldNow();
  }
}

const needsYield = () => performance.now() - started > 50;
const yieldNow = () => (scheduler.yield ? scheduler.yield()
                                        : new Promise((r) => setTimeout(r, 0)));

The other three fixes

  • Paint first, work after. Update the UI, await a yield, then do the expensive part. The user sees a response in one frame and never notices the rest.
  • Stop doing it on every event. input, scroll and pointermove fire constantly; debounce, or move the work into the animation frame.
  • Shrink the DOM. Presentation delay is layout and paint; a handler that touches a 10 000-node tree is slow no matter how tight the code is.

Practice