A interactive demo and workshop for learning about Interaction to Next Paint (INP)
- Clone this repository
npm install
npm run start
Use a cloud editor, such as CodeSandbox.io
- Open this repo in CodeSandbox.io.
- Create a branch so you can make edits
Just follow along with a static version: mmocny.com/inp-workshop/
At the top of the page is a simple "Score" counter and "Increment" button. A classic demo of reactivity and responsiveness!
Below are 4 metrics:
- INP: the current INP score, which is ~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 are not at all necessary for measuring Interactions. They are added just to make visualizing responsiveness a little easier.
Try to interact with the Increment
button and watch the score increase.
INP measures how long it takes-- from the moment the user interacts, until the page actually shows the rendered update to the user.
Just try the app, first!
What if you have some <script>
that randomly blocked the page some time after load?
// Block the page once every 3 seconds
setInterval(() => {
blockFor(1000);
}, 3000);
button.addEventListener("click", () => {
score.incrementAndUpdateUI();
});
- These long-running periods are often called Long Tasks.
- Notice, it doesn’t always affect interactions!
- If I'm lucky, I may not click while the long task is running.
- Such "random" sneezes can be a nightmare to debug when they only sometimes cause issues.
First Input Delay (FID) would measure such issues, only for the first interaction.
Basically: How often is the page "sneezing" during load, for long enough to affect responsiveness? But this is not all that affects responsiveness...
Open the index.js
file, and comment out the blockFor
function inside the event handler.
button.addEventListener("click", () => {
blockFor(1000);
score.incrementAndUpdateUI();
});
- Now what happens?
- What happens if you click multiple times, quickly?
- Slow responsiveness can motivate rage clicks-– and more rage clicks can further degrade responsiveness!
What happens if you swap the order of js calls -- incrementAndUpdateUI()
first?
Did you notice the UI appear earlier? Does the order affect INP scores?
button.addEventListener("click", () => {
score.incrementAndUpdateUI();
blockFor(1000);
});
What if you move the work to a separate Event Handlers?
button.addEventListener("click", () => {
score.incrementAndUpdateUI();
});
button.addEventListener("click", () => {
blockFor(1000);
});
What if we change the event types for the Event Handlers?
button.addEventListener("click", () => {
score.incrementAndUpdateUI();
});
button.addEventListener("focusin", () => {
blockFor(1000);
});
Most pages have many event handlers for many different event types, many of which block nexp paint and affect responsiveness.
- Most interactions will fire many types of events:
- touch, mouse, pointer, key, click events
- hover, focus, blur events
- select, beforechange, beforeinput, form submit, invalid, document beforeunload...
Any code running in any event handlers will delay the interaction.
- That includes handlers registered from different scripts...
- That includes framework or library code that runs in handlers.
- For example: a state update that triggers a component render.
- Not only your own code, but also all third party scripts.
Try to add a console.log
to your event handler.
When does it show in console? Is it delayed just like Next Paint? Does it matter if it is called before or after the call to blockFor
?
button.addEventListener("click", () => {
score.incrementAndUpdateUI();
console.log("Hello!");
blockFor(1000);
});
INP measures delays in visual updates (paint) after interactions... but not everything is visual.
- Console logs
- network requests
- local storage…
The web has a simple, but unique system for task scheduling and rendering.
So far, we’ve looked at Input delays and Processing of event handlers, but what else affects Interaction to Next Paint?
Well, the time spent actually rendering the page!
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, Paint.
- Long running Paint for complex scenes (for example, pages with very large amounts of text)
-
Off the main thread:
- Using CSS to power GPU effects
- Adding very large high-resolution images
- Using SVG/Canvas to draw complex scenes
DevTools can help you identify long interactions, and the cause of long delays.
This is a flow I find useful, every day:
- Navigate the web, as I normally do
- Leave DevTools console open, with the Web Vitals extension logging all interactions
- If I see a poorly performing interaction, I try to reproduce.
- If I can't repeat, I review the console logs to get insights
- If I can repeat, I record a trace in performance panel
What if your event handlers don't update the page at all, and only just schedule some work for the future? For example, await fetch(...)
.
Let's simulate this with setTimeout
.
button.addEventListener("click", () => {
setTimeout(() => {
blockFor(1000);
score.incrementAndUpdateUI();
}, 1000);
});
Notice that INP only measures the Next Paint, and doesn't measure this effect.
In this case, the Counter UI looks sluggish-- but the page itself is very responsive. Other styling, animations, accesibility features, are all very responsive.
...Unless I click again, during the async effect.
As long as the Next Paint after Interaction is allowed to render, Interaction to Next Paint measurement stops.
Your Next Paint immediately after the interaction is your first opportunity to provide visual feedback. The browser will often profice default feeback:
- User action pseudo classes (like
:hover
and:active
) trigger - Default UA actions:
- link clicks to navigate
- form submit
- checkbox checked
- Accessibility features
So, if async effects aren't measured-- perhaps that's a great place to hide our long-running code?
Let's change the click handler to delay the long blocking work by using a timeout.
button.addEventListener("click", () => {
score.incrementAndUpdateUI();
setTimeout(() => {
blockFor(1000);
}, 1000);
});
We likely still want the code to run as quickly as possible... otherwise we should have just removed it!
Goal:
- Interaction will UpdateUI
- BlockFor will run as soon as possible, but not block next paint
- Predictable behaviour without "magic timeouts"
Here are some ideas:
setTimeout(0)
Promise.then()
requestAnimationFrame
requestIdleCallback
scheduler.postTask()
scheduler.yield()
- Anything else?
button.addEventListener("click", () => {
score.incrementAndUpdateUI();
// Test one at a time!
setTimeout(() => {
blockFor(1000);
}, 0);
Promise.resolve().then(() => {
blockFor(1000);
});
requestAnimationFrame(() => {
blockFor(1000);
});
requestIdleCallback(() => {
blockFor(1000);
});
scheduler.postTask(() => {
blockFor(1000);
}, { priority: "background", delay: 0 }); // "user-visible", "background"
});
Answer
Trick: requestAnimationFrame
first, then setTimeout(0)
.
function afterNextPaint(callback) {
requestAnimationFrame(() => {
setTimeout(callback, 0);
});
}
button.addEventListener("click", () => {
score.incrementAndUpdateUI();
afterNextPaint(() => {
blockFor(1000);
});
});
Alternatively, my preference:
async function afterNextPaint() {
return new Promise(resolve => {
requestAnimationFrame(() => {
setTimeout(resolve, 0);
});
});
}
button.addEventListener("click", async () => {
score.incrementAndUpdateUI();
await afterNextPaint();
blockFor(1000);
});
And you may want a fallback, in case rendering is not scheduled at all:
async function afterNextPaint() {
return new Promise(resolve => {
requestAnimationFrame(() => {
setTimeout(resolve, 0);
});
// Fallback-- in case you are e.g. in a background tab
setTimeout(resolve, 1000);
});
}
- INP measures all Interactions
- Each Interaction is measured from Input to Next Paint -- the way the user sees responsiveness.
- Input Delay, (Event) Processing Times, Rendering and Presentation Delays, all affect Interaction responsiveness.
- You can measure INP, and Interaction breakdowns, easily!
Lessons:
- Don’t have long running code (long tasks) on your pages
- Move needless code out of event handlers until after next paint
- Make sure the Rendering update itself is efficient for browser
History: Input delays were a major problem on the web a few years ago-- and concepts such as Time to Interactive (TTI), Total Blocking Time (TBT), and First Input Delay (FID) were important to track. But some things have changed:
- Cookie prompts
- SSR + Lazy Hydration... until first interaction
- Expensive Client Side rendering, after interactions
- JS bloat: Analytics, 1p and 3p scripts hooking onto interactions
Today, Long Animation Frames, especailly for Interactions are a problem.
But new issues are also emerging: such as lazy loading and inherantly asynchronous event dispatch (i.e. many modern frameworks).
Moving long blocking work around can help -- but it still blocks the page -- affecting future interactions as well as many other page updates, such as (certain) animations.
Ideally, we want to remove Long Tasks completely!
Strategies:
- Remove unnecessary code altogether (especially scripts)
- Optimize tasks to not be maybe needlessly long-running
- Break up large chunks into smaller ones
- Abort stale work when a new interactions arrive.
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.
Useful for: autocomplete
- Use
setTimeout
to delay starting expensive work, with a timer-- perhaps 500-1000ms - save the timerid when you do so
- if a new interaction arrives,
clearTimeout
the previous
let timer;
button.addEventListener("click", () => {
score.incrementAndUpdateUI();
if (timer) {
clearTimeout(timer);
}
timer = setTimeout(() => {
blockFor(1000);
}, 1000);
});
Here’s what we want… even if we’re in the middle of work, we want to pause our busy work to start handling any new interactions, right away.
Challenge: How can we do that?
There are some apis like isInputPending
, but you get this by default if you just split long tasks up into chunks.
function blockInChunks(ms, chunks) {
for (let i = 0; i < chunks; i++) {
setTimeout(() => blockFor(ms / chunks), 0);
}
}
button.addEventListener("click", () => {
score.incrementAndUpdateUI();
blockInChunks(1000, 10);
});
This works by allowing the browser to schedule each task individually, and input can take higher priority!
This strategy works especially well when scheduling entry points -- like if you have a bunch of independant 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.
However, we can leverage modern JS access to async
and await
in order to easily add "yield points" to any JS function.
For example:
async function afterNextPaint() {
return new Promise(resolve => {
requestAnimationFrame(() => {
setTimeout(resolve, 0);
});
// Fallback-- in case you are e.g. in a background tab
setTimeout(resolve, 1000);
});
}
async function blockInChunks(ms, chunks) {
for (let i = 0; i < chunks; i++) {
blockFor(ms / chunks);
await scheduler.yield();
}
}
button.addEventListener("click", async() => {
score.incrementAndUpdateUI();
await afterNextPaint();
blockInChunks(1000, 10);
});
That worked... but it scheduled more work with each new interaction.
With Option 1: debounce()
, we cancelled the previous timeout with each new interaction. Can we do something similar here?
One easy way is to use an AbortController()
:
async function afterNextPaint() {
return new Promise(resolve => {
requestAnimationFrame(() => {
setTimeout(resolve, 0);
});
// Fallback-- in case you are e.g. in a background tab
setTimeout(resolve, 1000);
});
}
async function blockInChunks(ms, chunks, signal) {
for (let i = 0; i < chunks; i++) {
if (signal.aborted) return;
blockFor(ms / chunks);
await scheduler.yield();
}
}
let abortController;
button.addEventListener("click", async () => {
abortController?.abort();
abortController = new AbortController();
score.incrementAndUpdateUI();
await afterNextPaint();
blockInChunks(1000, 10, abortController.signal);
});
Breaking up all long tasks, allows a site to be responsive to new Interactions. That let's you provide initial feedabck quickly, and also lets you make decisions-- such as aborting in-progress work.
Sometimes that means scheduleding entry points as separate Tasks. Sometimes that means adding "yield" points where convenient.
And, review from earlier lessons:
- Don’t have long running code (long tasks) on your pages
- Move needless code out of event handlers until after next paint
- Make sure the Rendering update itself is efficient for browser