DEV Community

Cover image for INP explained: why Lighthouse cannot measure it, and how to find the pages that fail it
Joshua Smith
Joshua Smith

Posted on

INP explained: why Lighthouse cannot measure it, and how to find the pages that fail it

Interaction to Next Paint (INP) replaced First Input Delay as a Core Web Vital in March 2024, and it is the one many sites find hardest to fix, partly because the usual lab tools cannot show it. This post covers what INP measures, why Lighthouse cannot produce it, what Total Blocking Time (TBT) does and does not tell you, and a script that lists the pages failing INP in real-user data next to the lab evidence you can act on.

What INP measures

An interaction is a click, a tap or a key press; hovering and scrolling do not count. The browser times each one from the input to the next painted frame, in three parts:

  1. Input delay: the main thread is busy (a script starting up, a timer, an analytics call), so the handler waits.
  2. Processing time: your event handlers run.
  3. Presentation delay: the browser recalculates styles, lays out and paints the result.

A visit's INP is close to its slowest interaction; on pages with many interactions one outlier per 50 is ignored. Google reports the 75th percentile of visits over 28 days. Good is 200 ms or less, poor is above 500 ms, and in between needs improvement. FID measured only the input delay of the first interaction, so a page could pass it and still feel sluggish every time someone opened a menu.

Why lab tools cannot measure INP

A standard Lighthouse run, which is what PageSpeed Insights uses, loads the page and stops. Nobody clicks, so there is nothing to time. Even a scripted click would have to guess which interactions real users make, when, and on which device. The slow one is often something a lab run would never try: opening a filter drawer 40 seconds in, typing into a search box with live suggestions, adding to basket while a chat widget is still starting.

Lighthouse user flows in timespan mode and the Performance panel in Chrome DevTools do report INP for interactions you perform yourself. They are for debugging a page you already know is slow. To find which pages are slow, you need field data.

TBT: the lab proxy, and where it falls short

TBT adds up, between First Contentful Paint and the point the page is interactive, how far each long task (over 50 ms) exceeds 50 ms: a 120 ms task contributes 70 ms. Lighthouse rates mobile TBT of 200 ms or less as good. Long tasks are what make input wait, so heavy blocking during load suggests high input delay. It is an imperfect proxy:

  • It only covers loading. Long tasks triggered by the interaction itself, or by a timer two minutes in, are not counted.
  • It sees one of INP's three parts. Slow handlers and expensive re-rendering after a click are invisible to it.
  • The device is simulated. Real mid-range phones can be several times slower than Lighthouse's throttled CPU.
  • A lab load is not a real visit. Consent banners, logged-in states and scripts that load on user activity are often missing.

So: high TBT and failing INP is the easy case, and the failing performance audits point at the scripts. Low TBT and failing INP means the problem is in the interactions; go to DevTools. High TBT and passing INP is a load-time problem, not an INP one.

Finding failing pages in field data

PageSpeed Insights returns Chrome UX Report data with each audit: for INP, the 75th percentile and a bucket, FAST, AVERAGE (needs improvement) or SLOW. Two caveats matter more for INP than for other metrics:

  • Origin fallback. With too few real-user samples for a page, you get origin-level data, the whole site combined, so every page that falls back shows the same INP. That usually points at a site-wide script, but it cannot rank pages. Count it once per origin.
  • Missing INP. INP needs visits that include an interaction, so a page can have LCP data and no INP. Missing is not passing.

PageSpeed Insights Audit is an open-source Apify Actor that calls Google's official PageSpeed Insights API for a list of URLs and returns one flat record per URL and strategy with fieldData.inpMs, fieldData.originFallback, labMetrics.tbtMs and, with includeAuditDetails, the failing performance audits in opportunities. Start with mobile:

{
    "urls": [
        "https://shop.example/",
        "https://shop.example/category/shoes",
        "https://shop.example/product/runner",
        "https://shop.example/search?q=boots",
        "https://news.example/"
    ],
    "strategy": "mobile",
    "categories": ["performance"],
    "includeAuditDetails": true
}
Enter fullscreen mode Exit fullscreen mode

Paste the URLs into Page URLs in the Console and click Start, or, with the JSON saved as input.json:

curl -X POST "https://api.apify.com/v2/acts/josh99smith~pagespeed-insights-audit/run-sync-get-dataset-items?token=$APIFY_TOKEN" \
  -H "Content-Type: application/json" -d @input.json -o results.json
Enter fullscreen mode Exit fullscreen mode

The script

The script sorts audited records into page-level INP failures (AVERAGE or SLOW), worst first; origin-level failures, once per site, with the highest-TBT page as a lead; passing pages; and pages with no INP data. Each failure gets its lab TBT, a hint whether to look at load or at interactions, and up to three failing main-thread audits from opportunities. Save it as inp.py (Python 3.8 or later, no packages).

import json
import sys
from urllib.parse import urlsplit

TBT_GOOD = 200  # Lighthouse's "good" TBT on mobile, in ms
MAIN_THREAD = [  # failing performance audits about main-thread work, most direct first
    "bootup-time", "mainthread-work-breakdown", "long-tasks", "third-party-summary",
    "third-parties-insight", "dom-size", "dom-size-insight", "legacy-javascript",
    "legacy-javascript-insight", "duplicated-javascript", "duplicated-javascript-insight",
    "unused-javascript", "unminified-javascript",
]
BUCKET = {"SLOW": "poor", "AVERAGE": "needs improvement"}

rows = json.load(open(sys.argv[1], encoding="utf-8"))
ok = [r for r in rows if r["success"]]
page, origin, no_inp = [], {}, []
for r in ok:
    inp = (r["fieldData"] or {}).get("inpMs")
    if not inp:
        no_inp.append(r)
    elif inp["category"] in BUCKET:
        if r["fieldData"]["originFallback"]:
            origin.setdefault((r["strategy"], urlsplit(r["url"]).netloc), []).append(r)
        else:
            page.append(r)


def evidence(r):
    tbt = r["labMetrics"]["tbtMs"]
    audits = sorted((o for o in r.get("opportunities", []) if o["id"] in MAIN_THREAD),
                    key=lambda o: MAIN_THREAD.index(o["id"]))[:3]
    if tbt is None:
        lead = "no lab TBT"
    elif tbt > TBT_GOOD:
        lead = "busy main thread during load"
    else:
        lead = "load looks fine: profile the interactions"
    found = "; ".join(f"{o['title']} ({o['savingsMs'] or 0} ms)" for o in audits)
    return f"TBT {tbt} ms, {lead}\n      {found or 'no failing main-thread audits'}"


page.sort(key=lambda r: -r["fieldData"]["inpMs"]["percentile"])
print(f"PAGE-LEVEL INP FAILURES ({len(page)})")
for r in page:
    inp = r["fieldData"]["inpMs"]
    print(f"  {inp['percentile']:4} ms {BUCKET[inp['category']]:17} {r['strategy']:7} {r['url']}")
    print(f"      {evidence(r)}")

print(f"\nORIGIN-LEVEL INP FAILURES ({len(origin)}): one finding per site, not per page")
for (strategy, host), recs in origin.items():
    inp = recs[0]["fieldData"]["inpMs"]
    worst = max(recs, key=lambda r: r["labMetrics"]["tbtMs"] or 0)
    print(f"  {inp['percentile']:4} ms {BUCKET[inp['category']]:17} {strategy:7} {host}, {len(recs)} page(s)")
    print(f"      highest TBT: {worst['url']}\n      {evidence(worst)}")

passing = [r for r in ok if r not in no_inp and r not in page and all(r not in v for v in origin.values())]
heavy = [r for r in passing if (r["labMetrics"]["tbtMs"] or 0) > TBT_GOOD]
print(f"\nINP passes in the field: {len(passing)} ({len(heavy)} with lab TBT over {TBT_GOOD} ms)")
print(f"No INP field data: {len(no_inp)} (not the same as passing)")
print(f"Not audited (free): {len(rows) - len(ok)}", *sorted({r['errorType'] for r in rows if not r['success']}))
Enter fullscreen mode Exit fullscreen mode

Run python inp.py results.json. On 14 public pages, mostly large retail and news home pages (names replaced, numbers real), it printed:

PAGE-LEVEL INP FAILURES (5)
   495 ms needs improvement mobile  https://fashion-a.example/
      TBT 3595 ms, busy main thread during load
      Reduce JavaScript execution time (2650 ms); Minimize main-thread work (3600 ms); Reduce unused JavaScript (5600 ms)
   462 ms needs improvement mobile  https://retail-b.example/
      TBT 4603 ms, busy main thread during load
      Reduce JavaScript execution time (4850 ms); Minimize main-thread work (4600 ms); Legacy JavaScript (300 ms)
   [three more pages]

ORIGIN-LEVEL INP FAILURES (1): one finding per site, not per page
   203 ms needs improvement mobile  shop-f.example, 1 page(s)
      highest TBT: https://shop-f.example/pages/press
      TBT 546 ms, busy main thread during load
      Reduce JavaScript execution time (650 ms); Minimize main-thread work (550 ms); Reduce unused JavaScript (150 ms)

INP passes in the field: 4 (2 with lab TBT over 200 ms)
No INP field data: 0 (not the same as passing)
Not audited (free): 4 other timeout
Enter fullscreen mode Exit fullscreen mode

How to read the result

  • Page-level failures come first. All five here need improvement; none is poor.
  • High TBT with failing INP, as on every page above, means early taps wait for a busy main thread. Audits are listed most direct first: "Reduce JavaScript execution time" and "Minimize main-thread work" name the scripts and the kind of work.
  • The milliseconds next to each audit are Lighthouse's estimate on whichever metric it would improve most, which for "Reduce unused JavaScript" is usually a loading metric. Use them to pick a lead, not to predict INP.
  • "load looks fine: profile the interactions" means lab TBT is 200 ms or less but INP fails. Record a DevTools Performance trace with CPU throttling while you open the menu or type in search, and look at the interaction's three parts.
  • Origin-level failures are one site-wide finding. shop-f.example misses good by 3 ms on every page that fell back: look at tag managers, chat, consent and A/B testing scripts.
  • "2 with lab TBT over 200 ms" among passing pages is the proxy failing the other way.

After a fix, field INP moves gradually over the 28-day window, so re-run the list about four weeks later.

Cost and limits

The Actor charges $0.004 per successfully audited URL and strategy. The run above had 14 URLs on mobile: 10 audits succeeded and 4 failed (two timeout, two other), so it cost 10 x $0.004 = $0.04. The failures were free.

For a real site, audit the 50 or so templates and landing pages people interact with most: 50 x $0.004 = $0.20 on mobile, $0.40 with desktop. A monthly re-check for a year is 12 x $0.20 = $2.40.

Limits:

  • INP field data needs enough real Chrome visits with interactions. Low-traffic pages return origin-level data or nothing.
  • The audit is one lab load per URL. It never clicks, so TBT and the audits are evidence about load, not about specific interactions.
  • Heavy pages can take Google over a minute; raise Request timeout (timeoutSecs, default 120, up to 300) if you see many timeout records.
  • The default Google API key and its quota are shared by all users; when it runs out you get free rate-limited records. Your own free key in Google API key gives you 25,000 audits a day.
  • Public pages only: pages behind a login fail with http-error.

If this saves you time, a review on the Store page helps other people find it, and a star on GitHub helps too.

Disclosure: I built this Actor. Source: github.com/josh99smith/pagespeed-insights-audit. PageSpeed Insights, Lighthouse and Chrome are Google trademarks; the Actor is not affiliated with Google.

Top comments (0)