By Aditya Reddy
Say you have a page with 49 interactions. The slowest took 480 ms, followed by 320 ms and 240 ms. Everything else was faster. Your INP is 480 ms.
Now click something fast. You have changed no code, and that slow interaction still takes 480 ms. The score for the visit drops to 320 ms anyway.
That's the part of INP I wanted to explain in my Event Timing specification PR. The PR is still open. A single number on a dashboard hides quite a bit of bookkeeping.
What happened at click 50?
INP skips one longest interaction for every complete group of 50 interactions. At 49, it picks the longest. At 50, it picks the second longest. The INP guide explains the reason for excluding these outliers on pages with lots of interactions.
For our example, assuming all the extra interactions are below 240 ms:
| Interactions so far | Longest values skipped | INP candidate |
|---|---|---|
| 49 | 0 | 480 ms |
| 50 | 1 | 320 ms |
| 99 | 1 | 320 ms |
| 100 | 2 | 240 ms |
So you can cross two of these boundaries and go from 480 to 240 ms without fixing the 480 ms interaction. Nice dashboard, same slow click.
The selection code in web-vitals amounts to this, with the latencies sorted from longest to shortest:
const skip = Math.floor(interactionCount / 50);
const index = Math.min(skip, descendingLatencies.length - 1);
const candidate = descendingLatencies[index];
You need at least one candidate for this fragment. It is only the selection step, not a complete collector. web-vitals retains up to ten of the longest interactions, so the list can be much shorter than the interaction count.
Count interactions, not event entries
There is another place to get the numbers wrong before you even reach that selection.
A click can leave several PerformanceEventTiming entries. Take these made-up values from one visit:
| Interaction ID | Event | Duration |
|---|---|---|
| 7 | pointerdown | 320 ms |
| 7 | pointerup | 296 ms |
| 7 | click | 280 ms |
| 14 | click | 480 ms |
| 21 | keydown | 240 ms |
The first three rows belong to one interaction. Its latency is 320 ms, the longest duration in that group. Adding them gives 896 ms, but those durations overlap.
Five rows here give us three interactions, with latencies of 320, 480 and 240 ms. Group by the nonzero interactionId. Leave entries with ID zero out of this calculation. The Event Timing spec describes how those IDs group related events.
Your observer may have missed some of it
For entries of type event, the default duration threshold is 104 ms. You can register an observer with durationThreshold: 16 to receive shorter events going forward. That does not bring back all the fast events from before registration.
Even with buffered delivery, you only get the earlier entries that were kept under the default threshold. This is covered in the spec's measurement section.
That's why the total interaction count and your observed list length need to stay separate. A fast interaction can increase the count without appearing in that list. Lowering the threshold late won't recover its duration.
I would use web-vitals for production collection. There is first-input handling, page visibility and back/forward cache restoration to account for too. Code running in one window also cannot see every interaction in an embedded frame.
When debugging the score, keep the selected interaction's ID and latency along with the total count. If the score drops, check which interaction it picked. And if you are fixing that 480 ms click, compare the click itself before and after the change under similar conditions.
The proposed spec section puts the grouping, selection, observer threshold and retained-list limit together. The fast click at interaction 50 is a useful example of why those details matter.
Top comments (0)