Five numbers and a blank
I run a Core Web Vitals checker. Pointed at its own URL this morning, on a simulated mid-range phone over throttled 4G, it returned this:
LCP Largest Contentful Paint 3.32 s Needs work
CLS Cumulative Layout Shift 0 Good
INP Interaction to Next Paint (blank) Not measured
TTFB Time to First Byte 3 ms Good
FCP First Contentful Paint 1.71 s Good
TBT Total Blocking Time 78 ms Good
Tested whattofixfirst.com on mobile, 29 Sept 2026, 11:47.
Lighthouse performance score: 89/100.
Five numbers and a blank. The blank is one of the three Core Web Vitals, and no lab-only test can fill it, including the one I built.
That is not a bug in any of them. It is what the metric is.
INP needs a person
LCP and CLS can be observed by loading a page and watching it. INP cannot, because there is nothing to observe until somebody touches something.
Google's own documentation for the metric is direct about the conditions that produce no value at all. A page returns no INP when the user never clicked, tapped or pressed a key; when they only scrolled or hovered, neither of which counts; or when "the page is being accessed by a bot such as a search crawler or headless browser that has not been scripted to interact with the page."
That last line describes every lab run. Lighthouse loads the page in a headless browser and does not click anything, so there is no interaction, so there is no latency to report. The same documentation says so plainly: "some lab tools won't report a page's INP because they only observe the loading of a page without any interactions."
The only three interaction types INP observes are a mouse click, a tap on a touchscreen, and a key press on a physical or onscreen keyboard. Scrolling and hovering are excluded outright.
So a checker has two honest options. Report the blank and say why, or report something else and pretend it is the same thing. I went with the blank.
TBT is a stand-in, not a substitute
The usual advice is to read Total Blocking Time instead, and that advice is reasonable as far as it goes. Google phrases it with a caveat worth copying verbatim: TBT "may be a reasonable proxy metric for INP, but it's not a substitute for INP in and of itself."
The reason is in the thresholds. INP is assessed at the 75th percentile of page loads in the field, and the bands are 200 ms or less for good, above 200 ms up to 500 ms for needs improvement, and above 500 ms for poor. TBT measures main thread blocking during load only. A page can load with almost nothing blocking the main thread, score well on TBT, and then hang for 400 ms the first time someone opens a menu. My own check above reports TBT at 78 ms, which is good, and that tells you approximately nothing about what happens when a visitor types in the URL box.
Field data closes the gap when it exists. On this run it did not: the checker reported that Google does not yet have enough real-visitor data for the origin, which is the normal state for a small site. Blank in the lab, blank in the field, and the output says so twice rather than filling the space.
Collecting it yourself
If you want the number for your own page, you collect it from real visitors with the Event Timing API. Here is the version most people write first:
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.log(entry.name, entry.duration);
}
}).observe({ type: 'event', buffered: true });
This runs, it throws no errors, and on a responsive page it logs nothing at all. There are three reasons, and all three are written into the spec rather than hidden in browser behaviour.
The default cutoff is 104 ms
The Event Timing API does not surface every event. The W3C Working Draft of 19 March 2026 sets a default duration threshold of 104 ms, and explains the number: it "is just the first multiple of 8 greater than 100ms. An event whose rounded duration is greater than or equal to 104ms will have its pre-rounded duration greater than or equal to 100ms."
So the observer above is, by default, a slow-interaction detector. A page where every interaction lands in 90 ms produces an empty log, and an empty log looks identical to a broken observer.
Durations are rounded to 8 ms
That "rounded duration" is not incidental. The spec picked 8 ms as the granularity because it "allows relatively precise timing even for 120Hz displays." Every duration you read has been rounded to a multiple of 8, which is why the default cutoff is 104 and not 100. If you are comparing two interactions that differ by 5 ms, you are comparing noise.
The floor on durationThreshold is 16 ms
You can lower the threshold, but not to zero. The minimum allowed value is 16 ms, and the spec's reasoning is about frames rather than milliseconds: "In 120Hz displays, a response that skips more than a single frame will be at least 16ms, so the entry corresponding to this user input will be surfaced in the API under the minimum value."
A response that skips no more than a single frame is, as far as this API is concerned, not worth a record.
first-input is the exception
There is one entry type that ignores all of this. The spec says the first input entry "is reported even if it does not exceed a provided durationThreshold, and is buffered even if it does not exceed the default duration threshold of 104ms."
That matters because it is the only way to guarantee a page with interactions reports something. Google's guidance says the same: observe first-input alongside event so that consistently fast pages still produce a value instead of looking like pages nobody touched.
Put together, the version that actually collects data looks like this:
const interactions = [];
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.interactionId) {
interactions.push(entry.duration);
}
}
}).observe({ type: 'event', buffered: true, durationThreshold: 16 });
// Guaranteed to fire even on a very fast page.
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
interactions.push(entry.duration);
}
}).observe({ type: 'first-input', buffered: true });
Four things that still will not be right
Even with that, a hand-rolled collector disagrees with what Google records, in four specific ways that Google documents.
The percentile. INP is not the worst interaction. It is approximately the 98th percentile across all interactions on the page, computed when the page is unloaded. Keeping every sample in memory to compute that exactly is wasteful, and the documented shortcut is to keep only the worst N interactions, with 10 as the common choice.
Back/forward cache. If a page is restored from bfcache, INP resets to zero, because a user experiences that as a separate visit. A collector that keeps accumulating across a restore reports a number nobody experienced.
Backgrounded tabs. People leave tabs open for weeks and mobile browsers often do not run unload callbacks for background tabs, so waiting for unload loses the data. The fix is to report on visibilitychange, which covers both backgrounding and unload, and to compute the final value server side.
Iframes. The API does not report event entries from inside iframes. The metric does count them, because a visitor clicking play on an embedded video has no idea they crossed a frame boundary. This is a documented source of disagreement between CrUX and your own monitoring, and closing it means having the subframe post its entries up to the parent.
That list is why the practical answer for most people is onINP from the web-vitals library, which handles all of it except the iframe case. I am not arguing against using it. I am arguing that you should know what it is doing, because when your own dashboard and Search Console disagree, one of these four is usually the reason.
Two lines you can run right now
Open the console on any page you have interacted with:
PerformanceObserver.supportedEntryTypes.filter(t => /event|input/.test(t));
// ["event", "first-input"] in Chrome, checked 29 Sept 2026
performance.eventCounts.get('click');
// how many click events this page has recorded
The second one is the cheapest sanity check available. If eventCounts is counting clicks and your observer array is still empty, your observer is not broken. It is doing exactly what the default 104 ms threshold tells it to do, and your page is fast.
Which brings it back to the blank cell. A lab tool reporting no INP is not missing a feature. It is reporting, correctly, that nobody was there.
Written with AI assistance from my own notes and test results. I checked every fact and command before publishing. The cover image was generated with DEV's built-in image tool.
Top comments (0)