GitHub - franzenzenhofer/inp-analyser: Find out WHY a page has poor Interaction to Next Paint: element, phase, script URL:line:function, third-party owner, ms and certainty over many runs. CLI + agent skill.

GitHub

25 min read Original article ↗

Find out why a page has poor Interaction to Next Paint, not just that it does.

CI License: MIT Node >= 26 Agent skill

Website · Quick start · Commands · How it works · Agent skill


What you get, for any URL:

  • The real INP - the CrUX field p75 that PageSpeed Insights and Search Console show, checked digit for digit against PSI.
  • The issues behind it - which element, which INP phase, whose script (URL:line:function, third-party owner), how many milliseconds.
  • How sure that is - medians with 95% confidence intervals, reproducibility over many runs, causal proof for third parties.

It diagnoses. It never prints fixes: what to change is your call, made on evidence.

Built for and battle-tested in real-world INP audits of large, ad-heavy sites.

Contents

What is INP?

Interaction to Next Paint is the Core Web Vital for responsiveness. For every page visit, the browser measures how long each click, tap and key press takes until the next frame is painted; the page's INP is (close to) the slowest of them. Google reports the 75th percentile over 28 days of real Chrome users:

INP p75 Rating
<= 200 ms good
200 - 500 ms needs improvement
> 500 ms poor

Every interaction has three phases, and each has different causes (Optimize INP):

  1. Input delay - the main thread is busy (often a long task) when the user taps.
  2. Processing duration - the event handlers themselves run.
  3. Presentation delay - style, layout, paint and any script that squeezes in before the next frame.

Lighthouse and PSI give you a number. This tool gives you the reasons.

What it finds

Every issue in the report carries the same set of facts. The examples are real output from examples/test-site-third-party (the bundled test site, a third-party script that adds 200 ms to every click) and examples/wikipedia-united-states:

Fact Example Where it comes from
Element button#apply-sort; on Wikipedia div#mwBA > a:nth-of-type(2) Discovered interaction target (CSS selector)
Phase processing (one of input-delay, processing, presentation, context) Event Timing phases, split per interaction
Script knownDeltaListener (http://cdn.thirdparty.test:63958/known-delta.js:4:65) via #document.onclick (198 ms) JS samples in a Chrome trace: function, URL, line, column, invoker
Owner Test Tracker Inc. (3rd party) - blocking it made the tap 134 ms faster (95% CI 97-179 ms, 28 paired runs) third-party-web entity map, extendable with --entity-map
Milliseconds median 222 ms (95% CI 221-223 ms) Exact order-statistic CI of the median over the runs where the rule fired
Certainty confirmed, fired in 8 of 8 runs (95% CI 68-100%) Screening vs deep-dive runs, reproducibility interval

The rules it checks (each links its spec or engine source in the report):

Phase Rule IDs
Input delay input-delay/long-task, input-delay/third-party-task, input-delay/no-mobile-viewport, input-delay/boot-window
Processing processing/slow-event-handlers, processing/third-party-script, processing/forced-reflow, processing/layout-thrashing
Presentation presentation/large-style-recalc, presentation/large-layout, presentation/script-before-paint, presentation/rendering-dominant, presentation/large-dom-change, presentation/non-composited-animation
Context context/dom-size, context/page-exceptions, context/off-main-thread-time, context/measurement-artifact
Third parties third-party/causal-delta, third-party/confounded

Install

Not on npm yet. Install from source.

Requirements: Node.js >= 26, git, and roughly 500 MB of disk for the pinned Chrome for Testing build.

git clone https://github.com/franzenzenhofer/inp-analyser.git
cd inp-analyser
npm ci
npm run chrome:install   # downloads the pinned Chrome for Testing into .chrome/
npm run build
npm link                 # puts `inp` on your PATH

Without npm link, run the CLI straight from source:

npx tsx src/cli.ts --help

Every measurement runs on one pinned Chrome build, because INP semantics changed across Chrome releases (INP changelog). Chrome always runs headless.

Quick start

# 1. The real INP, as PSI shows it (no page loads, needs an API key)
inp field https://en.wikipedia.org/wiki/Main_Page

# 2. What the analyser would click, and what it refuses to click
inp discover https://en.wikipedia.org/wiki/Main_Page

# 3. A fast first look: screening runs only
inp analyse https://en.wikipedia.org/wiki/Main_Page --quick

# 4. The full analysis: screening, deep-dive, third-party causal tests
inp analyse https://en.wikipedia.org/wiki/Main_Page --out wiki-main

Then open wiki-main/report.html in a browser, or read wiki-main/report.json.

No API key yet? Add --no-field to analyse for a lab-only run.

Field data setup

Field data needs one Google Cloud API key with both of these APIs enabled on its project:

Create the key under APIs & Services > Credentials and set it as INP_GOOGLE_API_KEY:

cp .env.example .env               # .env is git-ignored; put the key in it
set -a && . ./.env && set +a       # or: export INP_GOOGLE_API_KEY=...

The CLI reads the environment only; it does not load .env by itself. Never commit a real key.

What it fetches per device (phone, desktop):

  • CrUX API record for the URL and for the origin.
  • CrUX History API (weekly 28-day periods) for both levels.
  • PSI loadingExperience / originLoadingExperience, compared with CrUX at both levels. Any difference is reported, never averaged away.

The headline is the URL-level p75 when CrUX has it, otherwise the origin-level p75, always labelled with its level, as PSI does.

Commands

Command What it does
inp field <url> CrUX + PSI field INP for phone and desktop. No page loads. Writes field.json.
inp discover <url> Lists the interaction targets analyse would measure (two passes, stable targets only), their safety class, excluded targets with the reason, and global third-party listeners.
inp analyse <url> The full pipeline: preflight, discovery, screening, deep-dive, third-party causal tests, diagnosis, report.
inp report <dir> Rebuilds report.json and report.html from a run directory (runs.jsonl + meta.json).
inp trace <file> Issues in an existing DevTools Performance recording (.json or .json.gz). One recording is one sample: no statistics.
inp compare <before> <after> Before/after on two run directories: Hodges-Lehmann shift + 95% CI, Mann-Whitney p (Holm-corrected), Cliff's delta per shared scenario.
inp calibrate Measures Lighthouse BenchmarkIndex under each CPU throttling rate on this machine and names the device class each rate reaches.

inp analyse options

Option Default Meaning
--device <mobile|desktop> mobile Mobile emulation with touch, or desktop with mouse
--cpu <rate> 4 CPU throttling rate (Lighthouse default). Pick it with inp calibrate.
--quick off Stop after screening (no deep-dive, no causal tests)
--max-targets <n> 12 Interaction targets to measure
--phases <list> boot,idle idle = tap once the page is loaded and quiet; boot = tap while the page is still booting
--budget <minutes> 120 Time budget for all stages; the report says if it ran out
--seed <n> random Seed for the randomised run order (printed in the report, so a run can be repeated)
--out <dir> inp-report-<host>-<timestamp> Output directory
--no-field field on Skip CrUX/PSI (lab only, no API key needed)
--entity-map <file> none Extra script owners: {"entities":[{"name","category","domains"}]}
--allow-mutations <regex> none First-party non-GET URLs allowed through the request guard
--min-load-interval <seconds> 10 Politeness: minimum seconds between loads of the same origin
--consent-state <file> none Seed an accepted-consent state (cookies + localStorage) into every load; capture one with scripts/capture-consent-state.ts
--max-load <n> half the cores Busy-machine limit (1-minute load average) for the start gate
--proxy-server <url> none Chrome proxy for page traffic, e.g. socks5://localhost:1080
--host-resolver-rules <rules> none Chrome host resolver rules (for the local test site only)

Other options: field --out <dir>, discover --device <d> --max-targets <n>, trace --entity-map <file>.

inp field - a real example

Fetched on 11 Nov 2026:

$ inp field https://en.wikipedia.org/wiki/Main_Page
phone  : INP p75 130 ms (url level, 2026-10-12..2026-11-08) good 86.5% / needs improvement 9.7% / poor 3.9% | PSI: equal
desktop: INP p75 61 ms (url level, 2026-10-12..2026-11-08) good 96.8% / needs improvement 2.3% / poor 0.9% | PSI: equal
  - CrUX API: p75 and good/NI/poor shares of real Chrome users over the 28-day collection period 2026-10-12..2026-11-08. https://developer.chrome.com/docs/crux/api
  - CrUX History API: 25 weekly 28-day periods, oldest first; ineligible periods are null. https://developer.chrome.com/docs/crux/history-api
  - PageSpeed Insights loadingExperience/originLoadingExperience (analysed 2026-11-11T05:31:51.750Z / 2026-11-11T05:31:51.501Z): separate source next to CrUX, reports no collection period. https://developers.google.com/speed/docs/insights/rest/v5/pagespeedapi/runpagespeed
  - CrUX PHONE vs PSI mobile, url level: identical (p75 130 ms, same good/NI/poor shares).
  - CrUX PHONE vs PSI mobile, origin level: identical (p75 87 ms, same good/NI/poor shares).
  - CrUX DESKTOP vs PSI desktop, url level: identical (p75 61 ms, same good/NI/poor shares).
  - CrUX DESKTOP vs PSI desktop, origin level: identical (p75 49 ms, same good/NI/poor shares).
[inp] field: inp-report-en.wikipedia.org-202611110531/field.json

Read it as: Wikipedia's Main Page has a good INP on both devices (phone 130 ms, desktop 61 ms at URL level; the whole origin is at 87 ms and 49 ms), and the CrUX API agrees with PSI to the last digit.

Examples: real runs

Unedited runs from 11 Nov 2026 live in examples/: terminal output, report.html, report.json, runs.jsonl. Mobile emulation, CPU 4x, Chrome for Testing 155, seed 42. The same cards, built from these files, are on the website.

1. A third-party listener, caught and proven (examples/test-site-third-party)

The bundled test site's ab-known-delta page: a third-party script (cdn.thirdparty.test/known-delta.js, owner "Test Tracker Inc." via --entity-map) adds exactly 200 ms to every click on the sort button. The tool finds it, names the function and line, and proves the cost by blocking:

$ inp analyse http://site.inp.test:63958/ab-known-delta --no-field --entity-map test-site/entity-map.json --budget 25 --max-targets 6 --seed 42 \
    --host-resolver-rules "MAP site.inp.test 127.0.0.1, MAP cdn.thirdparty.test 127.0.0.1"
Issues (4, ranked):
1. [needs improvement | confirmed | processing] Event handlers make the interaction slow (processing is the largest phase)
   button#apply-sort - lab @ CPU 4x, mobile: median 222 ms (95% CI 221-223 ms); fired in 8 of 8 runs (95% CI 68-100%)
   evidence: Test Tracker Inc. (3rd party): knownDeltaListener (http://cdn.thirdparty.test:63958/known-delta.js:4:65) via #document.onclick (198 ms)
2. [needs improvement | confirmed | processing] Third-party code runs inside the event handling (>= 50 ms, a long task)
   button#apply-sort - lab @ CPU 4x, mobile: median 197 ms (95% CI 194-199 ms); fired in 8 of 8 runs (95% CI 68-100%)
3. [needs improvement | confirmed | context] Third parties cause this delay: all third parties blocked is faster
   button#apply-sort - effect 135 ms (Hodges-Lehmann shift) (95% CI 91-197 ms); fired in 28 of 28 runs (95% CI 88-100%)
4. [needs improvement | confirmed | context] Third parties cause this delay: without Test Tracker Inc. is faster
   button#apply-sort - effect 134 ms (Hodges-Lehmann shift) (95% CI 97-179 ms); fired in 28 of 28 runs (95% CI 88-100%)
Run: 80 runs (0 excluded), 84 page loads, seed 42, Chrome/155.0.8059.39, lab @ CPU 4x, mobile

Element, phase, function (URL:line:column), owner, milliseconds with a confidence interval, and how often it fired: every fact from a real trace. The site is local, so there is no field number.

2. Wikipedia: field first, then where the lab risk is (examples/wikipedia-united-states)

$ inp analyse https://en.wikipedia.org/wiki/United_States --budget 50 --max-targets 6 --seed 42
Field INP PHONE: 103 ms (good)
  CrUX p75, PHONE, page (URL) level, 2026-10-12 to 2026-11-08
  real users: good 91.3% | needs improvement 6.7% | poor 2.1%
  PSI shows the same: 103 ms
Reconciliation: ... = 103 ms. 13 lab scenario(s) reach it: boot@5763:t-da82d59e07 (lab @ CPU 4x, mobile median 374 ms), ...
Issues (8, ranked):
1. [needs improvement | confirmed | input delay] Same element is slower when tapped while the page boots
   div#mwBA > a:nth-of-type(2) - lab @ CPU 4x, mobile: effect 199 ms (Hodges-Lehmann shift) (95% CI 116-265 ms); fired in 9 of 9 runs (95% CI 70-100%)
   evidence: boot median: 277 ms
2. [needs improvement | screening only | context] Most of a slow interaction is not main-thread work (compositor / GPU / frame wait)
   button#searchIcon - lab @ CPU 4x, mobile: median 258 ms; fired in 1 of 1 runs (95% CI 21-100%)
   evidence: not explained by main-thread work: 258 ms of 308 ms
4. [needs improvement | screening only | context] Large DOM (more than ~800 / ~1,400 elements, Lighthouse dom-size)
   evidence: DOM elements: 7489
...
Run: 147 runs (0 excluded), 151 page loads, seed 42, Chrome/155.0.8059.39, lab @ CPU 4x, mobile; INCOMPLETE: time budget reached before deep-dive block 102

How to read it:

  • The headline is the field number: 103 ms, good for 91.3% of real phone users, identical to PageSpeed Insights.
  • The confirmed lab finding: the hatnote link is 199 ms slower when tapped during page boot (277 ms) than on the idle page (78 ms), in 9 of 9 paired runs. Boot is where Wikipedia's mobile responsiveness risk sits.
  • The tool says what it does not know: the search button's slow tap is mostly not main-thread work, so no script is blamed; findings seen in one screening run stay screening only; the 50 min budget ended before the last deep-dive block, and the report says INCOMPLETE.

3. CNN: good in the field, nothing slow in the lab (examples/cnn-southern-taurids)

A science article on edition.cnn.com, run on an always-on Apple Silicon MacBook Air (8 cores) that was busy with other jobs, so the start gate was set to --max-load 6 (default: half the cores, 4); every measured block still passes the BenchmarkIndex health gate.

$ inp analyse https://edition.cnn.com/2026/11/04/science/southern-taurids-meteor-shower --budget 50 --max-targets 6 --seed 42 --max-load 6
Field INP PHONE: 115 ms (good)
  CrUX p75, PHONE, page (URL) level, 2026-10-13 to 2026-11-09
  real users: good 88.1% | needs improvement 6.9% | poor 5.0%
  PSI shows the same: 115 ms
Reconciliation: ... = 115 ms. No lab scenario reached it: not reproduced in the lab (real devices, logged-in states, iframes, untested interactions).
Issues (3, ranked):
1. [info | screening only | context] Large DOM (more than ~800 / ~1,400 elements, Lighthouse dom-size)
   evidence: DOM elements: 2178
2. [info | screening only | context] Uncaught exceptions on the page during the run
   evidence: exception: Script error.
3. [info | screening only | presentation delay] Non-composited animation runs during the interaction (needs layout/paint every frame)
   evidence: DIV id='onetrust-banner-sdk' ... visibility: failure code 8200; properties: visibility
Run: 18 runs (0 excluded), 22 page loads, seed 42, Chrome/155.0.8059.39, lab @ CPU 4x, mobile

How to read it: the page is good for real users (115 ms phone, 60 ms desktop, PSI identical at URL and origin level). None of the 6 measured scenarios was slower than 200 ms in screening (deep-dive on 0 of 6 scenarios), so there was no deep-dive and no causal test, and the tool says so instead of inventing a cause; the three findings are context, not attributed delays.

4. TechCrunch: field data only, lab pending (examples/techcrunch-basic-kindle)

$ inp field https://techcrunch.com/2026/11/07/amazons-new-basic-kindle-proves-you-dont-need-all-the-bells-and-whistles/
phone  : INP p75 229 ms (url level, 2026-10-13..2026-11-09) good 69.8% / needs improvement 22.7% / poor 7.5% | PSI: equal
desktop: INP p75 60 ms (url level, 2026-10-13..2026-11-09) good 94.0% / needs improvement 3.4% / poor 2.7% | PSI: equal

The field number is real and needs improvement on phones (229 ms; the whole origin is at 177 ms), identical in PSI. The lab analysis is pending: on 11 Nov 2026 one run's preflight page load timed out after 60 s, and the latest run met a Cloudflare challenge page. The tool stops at a challenge ([safety] bot wall ... Stopping: a challenge page is not the page, and retrying would hammer the site.) instead of measuring it, so this example holds field data only, and no lab numbers.

Page Field INP p75 phone (URL) Good / NI / poor Desktop Lab
Main_Page 130 ms 86.5% / 9.7% / 3.9% 61 ms no scenario reached the field p75, no issue
United_States 103 ms 91.3% / 6.7% / 2.1% 110 ms 8 issues, 1 confirmed (boot window, +199 ms)
CNN southern-taurids-meteor-shower 115 ms 88.1% / 6.9% / 5.0% 60 ms 3 context issues (screening only), no scenario over 200 ms, none reached the field p75
TechCrunch amazons-new-basic-kindle... 229 ms 69.8% / 22.7% / 7.5% 60 ms pending (timeout, then Cloudflare challenge: the tool stops)

Outputs

Every analyse writes one self-contained run directory:

File Contents
report.html Human report: field INP, reconciliation, issues with evidence, scenarios, boot hazard timeline, limitations. Self-contained, open it in any browser.
report.json Machine-readable result. Both renderers (terminal, HTML) read only this.
runs.jsonl One line per measured page load. The single source of truth: every report is rebuilt from it.
meta.json Run metadata: URL, seed, Chrome version, condition, targets, excluded targets, scenarios, number of loads.
field.json CrUX + CrUX History + PSI data and the parity check (also written by inp field).
traces/*.json.gz One Chrome trace per attribution run. Drag it into the DevTools Performance panel to see exactly what the report describes.

A terminal summary is printed at the end of every run.

Reading report.json

The top-level keys you will use most:

Key Meaning
field The field data (see above). The only number that may be called "INP".
reconciliation Does the lab explain the field number? verdict + plain-text text.
issues[] The diagnosed problems, sorted by severity and certainty.
comparisons[] Every statistical comparison: boot vs idle, third parties allowed vs blocked.
scenarios[] Per target + phase: median, CI, all values, whether it was deep-dived.
targets Measured targets, excluded targets (with the reason), global listeners.
run Runs, excluded runs, page loads, sentinel reference, incomplete if the budget ran out.
limitations What this lab run cannot see. Printed in every report.

issues[] fields

Field Meaning
ruleId, title Which rule fired, e.g. processing/forced-reflow
phase input-delay, processing, presentation or context
severity poor (> 500 ms), needs-improvement (> 200 ms) or info, from the scenario's median duration
certainty See below
targetSelector The element that was tapped or clicked
medianMs, medianCi The rule's quantity in ms with 95% CI (CI from 6 runs on)
quantity median of the rule's quantity, or shift (a Hodges-Lehmann difference between two arms)
reproducibility fired / runs with a Wilson 95% interval
evidence[] Label, value, ms and URL: script function (URL:line:column) with owner, phase timings, test statistics
specUrl The spec, web.dev article or engine source that defines why this counts

Certainty

Value Meaning
confirmed Deep-dived with at least 5 attribution runs, and the rule fired in at least 60% of them. A finding.
intermittent Deep-dived with at least 5 runs, but fired in fewer than 60%. Real, but not every time.
screening-only Seen in the first 3 screening runs only. A lead, not a finding.

Third parties: causal, not correlational

Attribution says "this third-party script ran during the slow interaction". That is a correlation. For deep-dived scenarios with at least 50 ms of third-party time, the tool runs paired blocks with third parties allowed and blocked (all of them, and leave-one-out for the top entities) and compares the arms:

  • third-party/causal-delta - blocking made the interaction faster: Holm-adjusted p < 0.05 and the shift CI excludes zero. medianMs is the time those third parties cost.
  • third-party/confounded - blocking broke first-party code (more page exceptions, or far fewer DOM changes), so the speed-up cannot be credited to the third party.

Consent managers are never blocked.

Reconciliation verdict

The lab never produces "the INP". It checks whether any lab scenario reaches the field p75:

verdict Meaning
reproduced At least one scenario's median CI reaches the field p75. The report names those scenarios.
not-reproduced No scenario reached it. The cause lies in what the lab cannot see: real devices, logged-in states, iframes, untested interactions.
no-field-data CrUX has no INP for the URL or the origin. Lab results stand alone and are not INP.

Lab values are always labelled lab @ CPU Nx (for example lab @ CPU 4x, mobile emulation).

How it works

flowchart TD
    A["Field data<br/>CrUX url + origin, CrUX history, PSI parity"] --> B
    B["Preflight + machine gate<br/>refuse battery, Low Power Mode, thermal limit, busy machine<br/>BenchmarkIndex, sentinel reference, no-click trace = boot hazard timeline"] --> C
    C["Discovery + safety guard<br/>two passes, stable targets only, deduped by handler<br/>denylist, request guard, popup guard"] --> D
    D["Screening<br/>3 seeded random blocks, every target idle + boot taps<br/>block 0 traced for attribution"] --> E
    E{"slowest run > 200 ms?"}
    E -- no --> H
    E -- yes --> F["Deep-dive<br/>timing blocks until the median CI is narrow enough, 8-20 runs<br/>then 5 attribution runs with traces"]
    F --> G["Third-party causal blocks<br/>10 paired blocks: allowed vs blocked, leave-one-out per entity"]
    G --> H["Rules<br/>17 detectors on every attribution run"]
    H --> I["Statistics<br/>median CIs, Wilson, Hodges-Lehmann, Mann-Whitney, Holm, Cliff's delta"]
    I --> J["Report<br/>reconciled against the field p75<br/>report.html, report.json"]
Loading
Stage What happens
Field CrUX + PSI per device, fetched before any page load (skipped with --no-field).
Preflight Machine gate (battery, Low Power Mode, thermal limit, load average). BenchmarkIndex. A sentinel page with a fixed 120 ms handler sets the noise reference. One no-click trace gives the main-thread hazard timeline (long tasks, FCP, busiest second).
Discovery Two passes; only targets found both times are kept, deduped by handler, ranked (above the fold first, buttons and handlers before links), capped at --max-targets. A boot-geometry probe records where each target sits at each boot offset.
Screening Every target idle, plus boot taps (by coordinates, at boot offsets taken from the hazard timeline) for targets above the fold. 3 interleaved blocks in seeded random order; block 0 records traces with JS samples.
Deep-dive Every scenario whose slowest screening run exceeded 200 ms. Timing blocks until the 95% CI half-width of the median is at most max(16 ms, 10%), with 8 to 20 runs; then 5 attribution runs. A boot scenario brings its idle twin along for the boot-vs-idle comparison.
Causal Up to 3 scenarios with >= 50 ms third-party time: 10 valid paired blocks (third parties allowed vs blocked, plus leave-one-out for the top 3 entities of the top 2 scenarios), then attribution runs on both arms for the confounding check.
Rules + stats 17 trace-based detectors per attribution run, plus issues from significant comparisons.
Report Issues, comparisons, scenarios and limitations, reconciled against the field p75.

Every block is health-gated: if BenchmarkIndex drifts by more than 10% or the sentinel moves outside max(16 ms, 15%) of its reference, the block is kept in runs.jsonl but excluded from all statistics. Failed blocks in the causal stage are replaced, up to twice the planned number.

Measurement details:

  • Interaction latency is the longest event per interactionId (on touch, the click can run long after pointerup has painted).
  • Phases come from Event Timing; exact durations come from the trace.
  • Interactions below the Event Timing threshold are kept as censored values at the lowest rank, never dropped.
  • Thresholds are the documented ones: 200/500 ms (web.dev), long task > 50 ms, forced reflow > 30 ms, large style recalc > 40 ms over > 300 elements, large layout > 40 ms over > 100 objects (DevTools insights), DOM size ~800/~1,400 elements (Lighthouse).

Methodology and statistics

Performance numbers are noisy and skewed. The tool uses distribution-free statistics throughout and states its uncertainty instead of hiding it.

Question Method
How slow is this interaction? Median with an exact order-statistic 95% CI (from 6 runs on), as scipy.stats.quantile_test
How often does this rule fire? Wilson score interval for the share of runs
Is boot slower than idle? Is it faster without third parties? Did the release help? Hodges-Lehmann shift with a Moses CI, Mann-Whitney U (exact for small samples), Cliff's delta as effect size
Many comparisons at once? Holm step-down correction over every confirmatory test of one report
When is a deep-dive precise enough? Stop once the median CI half-width <= max(16 ms, 10% of the median), 8 to 20 runs
  • Randomised order. Every block runs every scenario once, in a fresh order from a seeded PRNG (mulberry32). Interleaving protects against drift; the seed in the report makes the order reproducible.
  • Strictly sequential. One Chrome, one page load at a time. Never parallel measurements.
  • Reference-tested. Every statistic is unit-tested against R and scipy reference values (src/stats/*.test.ts).
  • Fail loud. Invalid input throws. Nothing is silently skipped.

Politeness and safety

The tool loads real production pages and clicks things on them. It is built to do that without harm:

  • Rate limit: at most one page load per origin every 10 seconds (--min-load-interval). The report states how many loads were made.
  • Denylist: targets whose label, aria-label, value, title or URL mean money, deletion, cancellation or logout (English, German, French, Italian, Spanish) are never clicked. Same for buttons in forms with password or payment fields, mailto:/tel:/sms: links and downloads. They are listed as excluded, with the reason.
  • Request guard: once a page has loaded, every main-frame navigation is answered with HTTP 204, so the page stays and the handler cost is still measured. Every first-party request that is not GET, HEAD or OPTIONS is failed, so a click on a submit button never reaches your server (allow specific URLs with --allow-mutations).
  • Popup guard: new tabs and windows are closed, JS dialogs are dismissed, downloads are denied.
  • Bot walls fail loud: a challenge page (Cloudflare, Akamai, Imperva, DataDome, PerimeterX) or a 403/429/503 main document stops the run instead of producing numbers from the wrong page.
  • No typing: no real form input is entered.
  • Headless only: Chrome never opens a window.

Agent skill

The repository ships an agent skill at skills/inp-analyser/SKILL.md for Claude Code and other agents that read the skill format. It teaches the agent when to use the tool, how to run it, how to read report.json, and to never present a lab number as the INP.

Install it by copying the folder:

cp -R skills/inp-analyser ~/.claude/skills/

Then just ask:

FAQ

Lab or field: which number is the INP? The field number. INP is defined on real users: the CrUX p75 over 28 days. The lab runs exist to explain why the field number is what it is. That is why the headline is always field, and lab values are always labelled lab @ CPU Nx.

Why does my lab number differ from PSI? PSI's INP comes from CrUX, not from a lab test (the Lighthouse part of PSI does not measure INP at all). Real users run different devices, tap different elements, are logged in, accept consent banners and interact at random moments. The lab measures a fixed set of interactions on one machine. Use the reconciliation verdict to see whether the lab reaches the field p75.

Does it need a quiet machine? Yes. Plug in the power supply, close heavy apps and run one measurement at a time. The tool refuses to start on battery, in Low Power Mode, under thermal throttling or above the load limit (--max-load), and it excludes any block in which the machine drifted.

Which --cpu should I use? It depends on your machine. Run inp calibrate: it measures Lighthouse BenchmarkIndex at each throttling rate and names the device class reached (low-end, mid-tier, high-end mobile, desktop). A fast laptop may need 6x instead of the default 4x to reach mid-tier mobile.

There is no CrUX data for my URL. CrUX only publishes pages and origins with enough real Chrome traffic. The tool falls back from URL to origin level automatically, and labels it. Without any data the verdict is no-field-data and the lab results stand alone. Use --no-field to skip the lookup entirely.

How long does a run take? --quick typically takes 20 to 40 minutes, depending on the number of targets and the 10-second politeness interval. A full analysis of an ad-heavy page can take one to two hours; --budget caps it.

Can I use it on staging or localhost? Yes. Note the rate limit applies per origin; lower --min-load-interval only for your own servers.

Limitations

Honest list. Every report also prints its own.

  • Lab is not field. Lab values come from one machine, one Chrome build, emulated devices. They explain; they do not replace CrUX.
  • Only discovered, safe interactions are measured: no logged-in states, no real typing, no cross-origin iframe content, no ads clicked.
  • Blocked requests hide their follow-up work. Work that depends on a mutating request's response or on a new document is not measured.
  • GPU work is not throttled. Chrome's CPU throttling does not slow down raster/GPU work, so presentation delay on real mid-tier phones can be larger.
  • Consent phase is planned, not yet a phase of its own. Use --consent-state to measure the consent-accepted page.
  • Machine gates are strongest on macOS: there the start gate also checks Low Power Mode and thermal throttling (pmset) and keeps the machine awake (caffeinate). On Linux it checks AC vs battery only; on other platforms only the load and BenchmarkIndex gates apply. Tested on macOS (Apple Silicon) and Linux CI.
  • Lost browsers are recovered, not hidden. On a machine under memory and CPU pressure Chrome can lose its page or its DevTools connection mid analysis. The tool then relaunches Chrome (at most 5 times per analysis), retries the lost run once and prints [browser] ... relaunching Chrome; the block gates still judge every block. INP_DEBUG_CHROME=1 shows Chrome's own log.
  • Diagnosis, not prescription. The tool never suggests fixes.

Contributing

Issues and pull requests are welcome. See CONTRIBUTING.md.

npm run typecheck && npm run lint && npm run test && npm run build
  • npm run test runs unit and end-to-end tests in real headless Chrome (no mocks).
  • npm run test-site starts the local test site (test-site/): one page per issue type plus a control page, with a fake third-party origin. Point the analyser at it with the printed --host-resolver-rules flag.
  • npm run test:field runs the live field-data tests (needs INP_GOOGLE_API_KEY).

Every detector has a test page that must fire its rule, and the control page must stay silent.

License

MIT © Franz Enzenhofer