Measuring Interaction to Next Paint (INP)

1. Introduction

This is an interactive codelab for learning how to measure Interaction to Next Paint (INP) using the web-vitals library.

Prerequisites

What you will learn

  • How to add the web-vitals library to your page and use its attribution data.
  • Use the attribution data to diagnose where and how to start improving INP.

What you will 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/measuring-inp.
  3. Install dependencies: npm ci.
  4. Start the web server: npm run start.
  5. Visit http://localhost:8080/ in your browser.

Try out the page

This codelab uses the Gastropodicon (a popular snail anatomy reference site) to explore potential issues with INP.

A screenshot of the Gastropodicon demo page

Try interacting with the page to get a feel for which interactions are slow.

3. Getting situated in 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.

In this codelab, we'll be using both the Performance panel and the Console. You can switch between these in the tabs at the top of DevTools at any time.

  • INP issues most often happen on mobile devices, so switch to mobile display emulation.
  • If you're testing on a desktop or laptop, performance will likely be significantly better than on a real mobile device. For a more realistic look at performance, hit the gear in the top right of the Performance panel, then select CPU 4x slowdown.

A screenshot of the DevTools Performance panel alongside the app, with 4x CPU slowdown selected

4. Installing web-vitals

web-vitals is a JavaScript library for measuring the Web Vitals metrics your users experience. You can use the library to capture those values, and then beacon them to an analytics endpoint for later analysis, for our purposes figuring out when and where slow interactions occur.

There are a few different ways to add the library to a page. How you'll install the library on your own site will depend on how you manage dependencies, the build process, and other factors. Be sure to check out the library's docs for all your options.

This codelab will install from npm and load the script directly to avoid diving into a particular build process.

There are two versions of web-vitals you can use:

  • The "standard" build should be used if you want to track the metric values of the Core Web Vitals on a page load.
  • The "attribution" build adds additional debug information to each metric to diagnose why a metric ends up with the value it does.

For measuring INP in this codelab, we want the attribution build.

Add web-vitals to the project's devDependencies by running npm install -D web-vitals

Add web-vitals to the page:

Add the attribution version of the script to the bottom of index.html and log the results to the console:

<script type="module">
  import {onINP} from './node_modules/web-vitals/dist/web-vitals.attribution.js';

  onINP(console.log);
</script>

Try it out

Try interacting with the page again with the console open. As you click around the page, nothing is logged!

INP is measured throughout the entire lifecycle of a page, and so by default, web-vitals doesn't report INP until the user leaves or closes the page. This is the ideal behavior for beaconing for something like analytics, but it's less ideal for debugging interactively.

web-vitals provides an the reportAllChanges option for more verbose reporting. When enabled, not every interaction is reported, but every time there's an interaction slower than any preceding one, it's reported.

Try adding the option to the script and interacting with the page again:

<script type="module">
  import {onINP} from './node_modules/web-vitals/dist/web-vitals.attribution.js';

  onINP(console.log, {reportAllChanges: true});
</script>

Refresh the page and interactions should now be reported to the console, updating whenever there's a new slowest one. For example, try typing in the search box and then deleting the input.

A screenshot of the DevTools console with INP messages successfully printed to it

5. What's in an attribution?

Let's start with the very first interaction most users will have with the page, the cookie consent dialog.

Many pages will have scripts that need cookies triggered synchronously when cookies are accepted by a user, causing the click to become a slow interaction. That's what happens here.

Click Yes to accept (demo) cookies, and take a look at the INP data now logged to the DevTools console.

INP data object logged to the DevTools console

This top-level information is available in both the standard and attribution web-vitals builds:

{
  name: 'INP',
  value: 344,
  rating: 'needs-improvement',
  entries: [...],
  id: 'v4-1715732159298-8028729544485',
  navigationType: 'reload',
  attribution: {...},
}

The length of time starting from when the user clicked to the next paint was 344 milliseconds—a "needs improvement" INP. The entries array has all the PerformanceEntry values associated with this interaction—in this case, only one click event.

To find out what's going on during this time, however, we're most interested in the attribution property. To build the attribution data, web-vitals finds which Long Animations Frame (LoAF) overlaps with the click event. The LoAF can then provide detailed data about how time was spent during that frame, from the scripts that ran, to the time spent in a requestAnimationFrame callback, style, and layout.

Expand the attribution property to see more information. The data is much richer.

attribution: {
  interactionTargetElement: Element,
  interactionTarget: '#confirm',
  interactionType: 'pointer',

  inputDelay: 27,
  processingDuration: 295.6,
  presentationDelay: 21.4,

  processedEventEntries: [...],
  longAnimationFrameEntries: [...],
}

First, there's information about what was interacted with:

  • interactionTargetElement: a live reference to the element that was interacted with (if the element hasn't been removed from the DOM).
  • interactionTarget: a selector for finding the element within page.

Next, the timing is broken down in a high-level manner:

  • inputDelay: the time between when the user started the interaction (for example, clicked the mouse) and when the event listener for that interaction began to run. In this case, the input delay was only about 27 milliseconds, even with CPU throttling on.
  • processingDuration: the time it takes event listeners to run to completion. Often, pages will have multiple listeners for a single event (for example, pointerdown, pointerup, and click). If they all run in the same animation frame, they'll be coalesced into this time. In this case, processing duration takes 295.6 milliseconds—the bulk of the INP time.
  • presentationDelay: the time from when the event listeners have completed until the time the browser has finished painting the next frame. In this case, 21.4 milliseconds.

These INP phases can be a vital signal for diagnosing what needs to be optimized. The Optimize INP guide has more information on this subject.

Digging a little deeper, the processedEventEntries contains five events, as opposed to the single event in the top-level INP entries array. What's the difference?

processedEventEntries: [
  {
    name: 'mouseover',
    entryType: 'event',
    startTime: 1801.6,
    duration: 344,
    processingStart: 1825.3,
    processingEnd: 1825.3,
    cancelable: true
  },
  {
    name: 'mousedown',
    entryType: 'event',
    startTime: 1801.6,
    duration: 344,
    processingStart: 1825.3,
    processingEnd: 1825.3,
    cancelable: true
  },
  {name: 'mousedown', ...},
  {name: 'mouseup', ...},
  {name: 'click', ...},
],

The top-level entry is the INP event, in this case a click. The attribution processedEventEntries are all the events that were processed during the same frame. Notice that it includes other events like mouseover and mousedown, not just the click event. Knowing about these other events can be vital if they were also slow, as they all contributed to slow responsiveness.

Finally there's the longAnimationFrameEntries array. This may be a single entry, but there are cases where an interaction can spread across multiple frames. Here we have the simplest case with a single long animation frame.

longAnimationFrameEntries

Expanding the LoAF entry:

longAnimationFrameEntries: [{
  name: 'long-animation-frame',
  startTime: 1823,
  duration: 319,

  renderStart: 2139.5,
  styleAndLayoutStart: 2139.7,
  firstUIEventTimestamp: 1801.6,
  blockingDuration: 268,

  scripts: [{...}]
}],

There are a number of useful values here, like breaking out the amount of time spent styling. The Long Animation Frames API article goes more in depth on these properties. Right now we're primarily interested in the scripts property, which contains entries that provide details on the scripts responsible for the long-running frame:

scripts: [{
  name: 'script',
  invoker: 'BUTTON#confirm.onclick',
  invokerType: 'event-listener',

  startTime: 1828.6,
  executionStart: 1828.6,
  duration: 294,

  sourceURL: 'http://localhost:8080/third-party/cmp.js',
  sourceFunctionName: '',
  sourceCharPosition: 1144
}]

In this case, we can tell time was primarily spent in a single event-listener, invoked on BUTTON#confirm.onclick. We can even see the script source URL and the character position of where the function was defined!

Takeaway

What can be determined about this case from this attribution data?

  • The interaction was triggered by a click on the button#confirm element (from attribution.interactionTarget and the invoker property on a script attribution entry).
  • Time was spent primarily executing event listeners (from attribution.processingDuration compared to the total metric value).
  • The slow event listener code starts from a click listener defined in third-party/cmp.js (from scripts.sourceURL).

That's enough data to know where we need to optimize!

6. Multiple event listeners

Refresh the page so the DevTools console is clear and the cookie consent interaction is no longer the longest interaction.

Start typing in the search box. What does the attribution data show? What do you think is going on?

Attribution data

First, a high level scan of one example of testing the demo:

{
  name: 'INP',
  value: 1072,
  rating: 'poor',
  attribution: {
    interactionTargetElement: Element,
    interactionTarget: '#search-terms',
    interactionType: 'keyboard',

    inputDelay: 3.3,
    processingDuration: 1060.6,
    presentationDelay: 8.1,

    processedEventEntries: [...],
    longAnimationFrameEntries: [...],
  }
}

It's a poor INP value (with CPU throttling enabled) from a keyboard interaction with the input#search-terms element. The majority of the time—1061 milliseconds out of a 1072 millisecond total INP—was spent in processing duration.

The scripts entries are more interesting, however.

Layout thrashing

The first entry of the scripts array gives us some valuable context:

scripts: [{
  name: 'script',
  invoker: 'BUTTON#confirm.onclick',
  invokerType: 'event-listener',

  startTime: 4875.6,
  executionStart: 4875.6,
  duration: 497,
  forcedStyleAndLayoutDuration: 388,

  sourceURL: 'http://localhost:8080/js/index.js',
  sourceFunctionName: 'handleSearch',
  sourceCharPosition: 940
},
...]

The majority of processing duration occurs during this script execution, which is an input listener (the invoker is INPUT#search-terms.oninput). The function name is given (handleSearch), as is the character position inside the index.js source file.

There's a new property, however: forcedStyleAndLayoutDuration. This was the time spent within this script invocation where the browser was forced to relayout the page. In other words, 78% of the time—388 milliseconds out of 497—spent executing this event listener was actually spent in layout thrashing.

This should be a top priority to fix.

Repeated listeners

Individually, there's nothing especially remarkable about the next two script entries: