Understanding Interaction to Next Paint (INP)

1. Introduction

An interactive demo and codelab for learning about Interaction to Next Paint (INP).

A diagram depicting an interaction on the main thread. The user makes an input while blocking tasks run. The input is delayed until those tasks complete, after which the pointerup, mouseup, and click event listeners run, then rendering and painting work is kicked off until the next frame is presented

Prerequisites

  • Knowledge of HTML and JavaScript development.
  • Recommended: read the INP documentation.

What you learn

  • How the interplay of user interactions and your handling of those interactions affect page responsiveness.
  • How to reduce and eliminate delays for a smooth user experience.

What you need

  • A computer with the ability to clone code from GitHub and run npm commands.
  • A text editor.
  • A recent version of Chrome for all the interaction measurements to work.

2. Get set up

Get and run the code

The code is found in the the web-vitals-codelabs repository.

  1. Clone the repo in your terminal: git clone https://github.com/GoogleChromeLabs/web-vitals-codelabs.git
  2. Traverse into the cloned directory: cd web-vitals-codelabs/understanding-inp
  3. Install dependencies: npm ci
  4. Start the web server: npm run start
  5. Visit http://localhost:5173/understanding-inp/ in your browser

Overview of the app

Located at the top of the page is a Score counter and Increment button. A classic demo of reactivity and responsiveness!

A screenshot of the demo app for this codelab

Below the button there are four measurements:

  • INP: the current INP score, which is typically the worst interaction.
  • Interaction: the score of the most recent interaction.
  • FPS: the main thread frames-per-second of the page.
  • Timer: a running timer animation to help visualize jank.

The FPS and Timer entries are not at all necessary for measuring interactions. They are added just to make visualizing responsiveness a little easier.

Try it out

Try to interact with the Increment button and watch the score increase. Do the INP and Interaction values change with each increment?

INP measures how long it takes from the moment the user interacts until the page actually shows the rendered update to the user.

3. Measuring interactions with Chrome DevTools

Open DevTools from the More Tools > Developer Tools menu, by right clicking on the page and selecting Inspect, or by using a keyboard shortcut.

Switch to the Performance panel, which you'll use to measure interactions.

A screenshot of the DevTools Performance panel alongside the app

Next, capture an interaction in the Performance panel.

  1. Press record.
  2. Interact with the page (press the Increment button).
  3. Stop the recording.

In the resulting timeline, you'll find an Interactions track. Expand it by clicking on the triangle on the left hand side.

An animated demonstration of recording an interaction using the DevTools performance panel

Two interactions appear. Zoom in on the second one by scrolling or holding the W key.

A screenshot of the DevTools Performance panel, the cursor hovering over the interaction in the panel, and a tooltip listing the short timing of the interaction

Hovering over the interaction, you can see the interaction was fast, spending no time in processing duration, and a minimum amount of time in input delay and presentation delay, the exact lengths of which will depend on the speed of your machine.

4. Long-running event listeners

Open the index.js file, and uncomment the blockFor function inside the event listener.

See full code: click_block.html

button.addEventListener('click', () => {
  blockFor(1000);
  score.incrementAndUpdateUI();
});

Save the file. The server will see the change and refresh the page for you.

Try interacting with the page again. The interactions will now be noticeably slower.

Performance trace

Take another recording in the Performance panel to see what this looks like there.

A one-second-long interaction in the Performance panel

What was once a short interaction now takes a full second.

When you hover over the interaction, notice the time is almost entirely spent in "Processing duration", which is the amount of time taken to execute the event listener callbacks. Since the blocking blockFor call is entirely within the event listener, that's where the time goes.

5. Experiment: processing duration

Try out ways of rearranging the event-listener work to see the effect on INP.

Update UI first

What happens if you swap the order of js calls—update the UI first, then block?

See full code: ui_first.html

button.addEventListener('click', () => {
  score.incrementAndUpdateUI();
  blockFor(1000);
});

Did you notice the UI appear earlier? Does the order affect INP scores?

Try taking a trace and examining the interaction to see if there were any differences.

Separate listeners

What if you move the work to a separate event listener? Update the UI in one event listener, and block the page from a separate listener.

See full code: two_click.html

button.addEventListener('click', () => {
  score.incrementAndUpdateUI();
});

button.addEventListener('click', () => {
  blockFor(1000);
});

What does it look like in the performance panel now?

Different event types

Most interactions will fire many types of events, from pointer or key events, to hover, focus/blur, and synthetic events like beforechange and beforeinput.

Many real pages have listeners for many different events.

What happens if you change the event types for the event listeners? For example, replace one of the click event listeners with pointerup or mouseup?

See full code: diff_handlers.html

button.addEventListener('click', () => {
  score.incrementAndUpdateUI();
});

button.addEventListener('pointerup', () => {
  blockFor(1000);
});

No UI update

What happens if you remove the call to update UI from the event listener?

See full code: no_ui.html

button.addEventListener('click', () => {
  blockFor(1000);
  // score.incrementAndUpdateUI();
});

6. Processing duration experiment results

Performance trace: update UI first

See full code: ui_first.html

button.addEventListener('click', () => {
  score.incrementAndUpdateUI();
  blockFor(1000);
});

Looking at a Performance panel recording of clicking the button, you can see that the results did not change. While a UI update was triggered before the blocking code, the browser didn't actually update what was painted to screen until after the event listener was complete, which means the interaction still took just over a second to complete.

A still one-second-long interaction in the Performance panel

Performance trace: separate listeners

See full code: two_click.html

button.addEventListener('click', () => {
  score.incrementAndUpdateUI();
});

button.addEventListener('click', () => {
  blockFor(1000);
});

Again, there's functionally no difference. The interaction still takes a full second.

If you zoom way into the click interaction, you'll see that there are indeed two different functions being called as a result of the click event.

As expected, the first—updating the UI—runs incredibly quickly, while the second take a full second. However, the sum of their effects results in the same slow interaction to the end user.

A zoomed-in look at the one-second-long interaction in this example, showing the first function call taking less than a millisecond to complete

Performance trace: different event types

button.addEventListener('click', () => {
  score.incrementAndUpdateUI();
});

button.addEventListener('pointerup', () => {
  blockFor(1000);
});

These results are very similar. The interaction is still a full second; the only difference is that the shorter UI-update-only click listener now runs after the blocking pointerup listener.

A zoomed-in look at the one-second-long interaction in this example, showing the click event listener taking less than a millisecond to complete, after the pointerup listener

Performance trace: no UI update

See full code: no_ui.html

button.addEventListener('click', () => {
  blockFor(1000);
  // score.incrementAndUpdateUI();
});
  • The score doesn't update, but the page still does!
  • Animations, CSS effects, default web component actions (form input), text entry, text highlighting all continue to update.

In this case the button goes to an active state and back when clicked, which requires a paint by the browser, which means there's still an INP.

Since the event listener blocked the main thread for a second preventing the page from being painted, the interaction still takes a full second.

Taking a Performance panel recording shows the interaction virtually identical to those that came before.

A still one-second-long interaction in the Performance panel

Takeaway

Any code running in any event listener will delay the interaction.

  • That includes listeners registered from different scripts and framework or library code that runs in listeners, such as a state update that triggers a component render.
  • Not only your own code, but also all third party scripts.

It's a common problem!

Finally: just because your code doesn't trigger a paint doesn't mean a paint won't be waiting on slow event listeners to complete.

7. Experiment: input delay

What about long running code outside of event listeners? For example:

  • If you had a late-loading <script> that randomly blocked the page during load.
  • An API call, such as setInterval, that periodically blocks the page?

Try removing the blockFor from the event listener and adding it to a setInterval():

See full code: input_delay.html

setInterval(() => {
  blockFor(1000);
}, 3000);


button.addEventListener('click', () => {
  score.incrementAndUpdateUI();
});

What happens?

8. Input delay experiment results

See full code: input_delay.html

setInterval(() => {
  blockFor(1000);
}, 3000);


button.addEventListener('click', () => {
  score.incrementAndUpdateUI();
});

Recording a button click that happens to occur while the setInterval blocking task was running results in a long-running interaction, even with no blocking work being done in the interaction itself!

These long-running periods are often called long tasks.

Hovering over the interaction in DevTools, you'll be able to see the interaction time is now primarily attributed to input delay, not processing duration.

The DevTools Performance panel showing an one-second blocking task, an interaction coming in part way through that task, and a 642 millisecond interaction, mostly attributed to input delay

Notice, it doesn't always affect the interactions! If you don't click when the task is running, you may get lucky. Such "random" sneezes can be a nightmare to debug when they only sometimes cause issues.

One way to track these down is through measuring long tasks (or Long Animation Frames), and Total Blocking Time.

9. Slow presentation

So far, we've looked at the performance of JavaScript, via input delay or event listeners, but what else affects rendering next paint?

Well, updating the page with expensive effects!

Even if the page update comes quickly, the browser may still have to work hard to render them!

On the main thread:

  • UI frameworks that need to render updates after state changes
  • DOM changes, or toggling many expensive CSS query selectors can trigger lots of Style, Layout, and Paint.

Off the main thread:

  • Using CSS to power GPU effects
  • Adding very large high-resolution images
  • Using SVG/Canvas to draw complex scenes

Sketch of the different elements of rendering on the web

RenderingNG

Some examples commonly found on the web:

  • An SPA site that rebuilds the entire DOM after clicking a link, without pausing to provide an initial visual feedback.
  • A search page that offers complex search filters with a dynamic user interface, but runs expensive listeners to do so.
  • A dark mode toggle that triggers style/layout for the whole page

10. Experiment: presentation delay

Slow requestAnimationFrame

Let's simulate a long presentation delay using the requestAnimationFrame() API.

Move the blockFor call into a requestAnimationFrame callback so it runs after the event listener returns:

See full code: presentation_delay.html

button.addEventListener('click', () => {
  score.incrementAndUpdateUI();
  requestAnimationFrame(() => {
    blockFor(1000);
  });
});

What happens?

11. Presentation delay experiment results

See full code: presentation_delay.html

button.addEventListener('click', () => {
  score.incrementAndUpdateUI();
  requestAnimationFrame(() => {
    blockFor(1000);
  });
});

The interaction remains a second long, so what happened?

requestAnimationFrame requests a callback before the next paint. Since INP measures the time from the interaction to the next paint, the blockFor(1000) in the requestAnimationFrame continues to block the next paint for a full second.

A still one-second-long interaction in the Performance panel

However, notice two things:

  • On hover, you'll see all the interaction time is now being spent in "presentation delay" since the main-thread blocking is happening after the event listener returns.
  • The root of the main-thread activity is no longer the click event, but "Animation Frame Fired".

12. Diagnosing interactions

On this test page, responsiveness is super visual, with the scores and timers and the counter UI...but when testing the average page it's more subtle.

When interactions do run long, it's not always clear what the culprit is. Is it:

  • Input delay?
  • Event processing duration?
  • Presentation delay?

On any page you want, you can use DevTools to help measure responsiveness. To get into the habit, try this flow:

  1. Navigate the web, as you normally would.
  2. Optional: leave the DevTools console open while the Web Vitals extension logs interactions.
  3. If you see a poorly performing interaction, try to repeat it:
  • If you can't repeat it, use the console logs to get insights.
  • If you can repeat it, record in the performance panel.

All the delays

Try adding a bit of all these problems to the page:

See full code: all_the_things.html

setInterval(() => {
  blockFor(1000);
}, 3000);

button.addEventListener('click', () => {
  blockFor(1000);
  score.incrementAndUpdateUI();

  requestAnimationFrame(() => {
    blockFor(1000);
  });
});

Then use the console and performance panel to diagnose the issues!

13. Experiment: async work

Since you can start non-visual effects inside interactions, such as making network requests, starting timers, or just updating global state, what happens when those eventually update the page?

As long as the next paint after an interaction is allowed to render, even if the browser decides it doesn't actually need a new rendering update, Interaction measurement stops.

To try this out, continue updating the UI from the click listener, but run the blocking work from the timeout.

See full code: timeout_100.html

button.addEventListener('click', () => {
  score.incrementAndUpdateUI();

  setTimeout(() => {
    blockFor(1000);
  }, 100);
});

What happens now?

14. Async work experiment results

See full code: timeout_100.html

button.addEventListener('click', () => {
  score.incrementAndUpdateUI();

  setTimeout(() => {
    blockFor(1000);
  }, 100);
});

A 27 millisecond interaction with a one second long task now occurring later in the trace

The interaction is now short because the main thread is available immediately after the UI is updated. The long blocking task still runs, it just runs sometime after the paint, so the user will get immediate UI feedback.

Lesson: if you cannot remove it, at least move it!

Methods

Can we do better than a fixed 100 millisecond setTimeout? We likely still want the code to run as quickly as possible, otherwise we should have just removed it!

Goal:

  • The interaction will run incrementAndUpdateUI().
  • blockFor() will run as soon as possible, but not block the next paint.
  • This results in predictable behaviour without "magic timeouts".

Some ways to accomplish this involve:

  • setTimeout(0)
  • Promise.then()
  • requestAnimationFrame
  • requestIdleCallback
  • scheduler.postTask()

"requestPostAnimationFrame"

Unlike requestAnimationFrame on its own (which will attempt to run before the next paint and will usually still make for a slow interaction), requestAnimationFrame + setTimeout makes for a simple polyfill for requestPostAnimationFrame, running the callback after the next paint.

See full code: raf+task.html

function afterNextPaint(callback) {
  requestAnimationFrame(() => {
    setTimeout(callback, 0);
  });
}

button.addEventListener('click', () => {
  score.incrementAndUpdateUI();

  afterNextPaint(() => {
    blockFor(1000);
  });
});

For ergonomics, you can even wrap it in a promise:

See full code: raf+task2.html

async function nextPaint() {
  return new Promise(resolve => afterNextPaint(resolve));
}

button.addEventListener('click', async () => {
  score.incrementAndUpdateUI();

  await nextPaint();
  blockFor(1000);
});

15. Multiple interactions (and rage clicks)

Moving long blocking work around can help, but those long tasks still block the page, affecting future interactions as well as many other page animations and updates.

Try the async blocking work version of the page again (or your own if you came up with your own variation on deferring work in the last step):

See full code: timeout_100.html

button.addEventListener('click', () => {
  score.incrementAndUpdateUI();

  setTimeout(() => {
    blockFor(1000);
  }, 100);
});

What happens if you click multiple times quickly?

Performance trace

For each click, there's a one-second-long task queued up, ensuring the main thread is blocked for a substantial amount of time.

Multiple second-long tasks in the main thread, causing interactions as slow as 800ms

When those long tasks overlap with new clicks coming in, it results in slow interactions even though the event listener itself returns almost immediately. We've created the same situation as in the earlier experiment with input delays. Only this time, the input delay isn't coming from a setInterval, but from work triggered by earlier event listeners.

Strategies

Ideally, we want to remove long tasks completely!

  • Remove unnecessary code altogether—especially scripts.
  • Optimize code to avoid running long tasks.
  • Abort stale work when new interactions arrive.

16. Strategy 1: debounce

A classic strategy. Whenever interactions arrive in quick succession, and the processing or network effects are expensive, delay starting work on purpose so you can cancel and restart. This pattern is useful for user interfaces such as autocomplete fields.

  • Use setTimeout to delay starting expensive work, with a timer, perhaps 500 to 1000 milliseconds.
  • Save the timer ID when you do so.
  • If a new interaction arrives, cancel the previous timer using clearTimeout.

See full code: debounce.html

let timer;
button.addEventListener('click', () => {
  score.incrementAndUpdateUI();

  if (timer) {
    clearTimeout(timer);
  }
  timer = setTimeout(() => {
    blockFor(1000);
  }, 1000);
});

Performance trace

Multiple interactions, but only a single long task of work as a result of all of them

Despite multiple clicks, only one blockFor task ends up running, waiting until there haven't been any clicks for a full second before running. For interactions that come in bursts—like typing in a text input or items targets that are expected to get multiple quick clicks—this is an ideal strategy to use by default.

17. Strategy 2: interrupt long running work

There's still the unlucky chance a further click will come in just after the debounce period has passed, will land in the middle of that long task, and become a very slow interaction due to input delay.

Ideally if an interaction comes in the middle of our task, we want to pause our busy work so any new interactions are handled right away. How can we do that?

There are some APIs like isInputPending, but it's generally better to split long tasks up into chunks.

Lots of setTimeouts

First attempt: do something simple.

See full code: small_tasks.html

button.addEventListener('click', () => {
  score.incrementAndUpdateUI();

  requestAnimationFrame(() => {
    setTimeout(() => blockFor(100), 0);
    setTimeout(() => blockFor(100), 0);
    setTimeout(() => blockFor(100), 0);
    setTimeout(() => blockFor(100), 0);
    setTimeout(() => blockFor(100), 0);
    setTimeout(() => blockFor(100), 0);
    setTimeout(() => blockFor(100), 0);
    setTimeout(() => blockFor(100), 0);
    setTimeout(() => blockFor(100), 0);
    setTimeout(() => blockFor(100), 0);
  });
});

This works by allowing the browser to schedule each task individually, and input can take higher priority!

Multiple interactions, but all the scheduled work has been broken down into many smaller tasks

We're back to a full five seconds of work for five clicks, but each one-second task per click has been broken up into ten 100 millisecond tasks. As a result—even with multiple interactions overlapping with those tasks—no interaction has any input delay over 100 milliseconds! The browser prioritizes the incoming event listeners over the setTimeout work, and interactions remain responsive.

This strategy works especially well when scheduling separate entry points—like if you have a bunch of independent features you need to call at application load time. Just loading scripts and running everything at script eval time may run everything in a giant long task by default.

However, this strategy doesn't work as well for breaking apart tightly-coupled code, like a for loop that uses shared state.

Now with yield()

However, we can leverage modern async and await in order to easily add "yield points" to any JavaScript function.

For example:

See full code: yieldy.html

// Polyfill for scheduler.yield()
async function schedulerDotYield() {
  return new Promise(resolve => {
    setTimeout(resolve, 0);
  });
}

async function blockInPiecesYieldy(ms) {
  const ms_per_part = 10;
  const parts = ms / ms_per_part;
  for (let i = 0; i < parts; i++) {
    await schedulerDotYield();

    blockFor(ms_per_part);
  }
}

button.addEventListener('click', async () => {
  score.incrementAndUpdateUI();
  await blockInPiecesYieldy(1000);
});

As before, the main thread is yielded after a chunk of work and the browser is able to respond to any incoming interactions, but now all that's required is an await schedulerDotYield() instead of separate setTimeouts, making it ergonomic enough to use even in the middle of a for loop.

Now with AbortContoller()

That worked, but each interaction schedules more work, even if new interactions have come in and might have changed the work that needs to be done.

With the debouncing strategy, we cancelled the previous timeout with each new interaction. Can we do something similar here? One way to do this is to use an AbortController():

See full code: aborty.html

// Polyfill for scheduler.yield()
async function schedulerDotYield() {
  return new Promise(resolve => {
    setTimeout(resolve, 0);
  });
}

async function blockInPiecesYieldyAborty(ms, signal) {
  const parts = ms / 10;
  for (let i = 0; i < parts; i++) {
    // If AbortController has been asked to stop, abandon the current loop.
    if (signal.aborted) return;

    await schedulerDotYield();

    blockFor(10);
  }
}

let abortController = new AbortController();

button.addEventListener('click', async () => {
  score.incrementAndUpdateUI();

  abortController.abort();
  abortController = new AbortController();

  await blockInPiecesYieldyAborty(1000, abortController.signal);
});

When a click comes in, it starts the blockInPiecesYieldyAborty for loop doing whatever work needs to be done while periodically yielding the main thread so the browser remains responsive to new interactions.

When a second click comes in, the first loop is flagged as cancelled with the AbortController and a new blockInPiecesYieldyAborty loop is started—the next time the first loop is scheduled to run again, it notices that signal.aborted is now true and immediately returns without doing further work.

Main thread work is now in many tiny pieces, interactions are short, and work only lasts as long as it needs to

18. Conclusion

Breaking up all long tasks allows a site to be responsive to new interactions. That lets you provide initial feedback quickly, and also lets you make decisions such as aborting in-progress work. Sometimes that means scheduling entry points as separate tasks. Sometimes that means adding "yield" points where convenient.

Remember

  • INP measures all interactions.
  • Each interaction is measured from input to next paint—the way the user sees responsiveness.
  • Input delay, event processing duration, and presentation delay all affect interaction responsiveness.
  • You can measure INP and interaction breakdowns with DevTools easily!

Strategies

  • Don't have long-running code (long tasks) on your pages.
  • Move needless code out of event listeners until after next paint.
  • Make sure the rendering update itself is efficient for browser.

Learn more