1. Introduction
An interactive demo and codelab for learning about Interaction to Next Paint (INP).
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.
- Clone the repo in your terminal:
git clone https://github.com/GoogleChromeLabs/web-vitals-codelabs.git
- Traverse into the cloned directory:
cd web-vitals-codelabs/understanding-inp
- Install dependencies:
npm ci
- Start the web server:
npm run start
- 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!
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.
Next, capture an interaction in the Performance panel.
- Press record.
- Interact with the page (press the Increment button).
- 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.
Two interactions appear. Zoom in on the second one by scrolling or holding the W key.
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.
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.
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.
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.
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.
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.
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
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.
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:
- Navigate the web, as you normally would.
- Optional: leave the DevTools console open while the Web Vitals extension logs interactions.
- 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);
});
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.
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
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 setTimeout
s
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!
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 setTimeout
s, 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.
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.