A click can get a poor INP without doing anything slow itself. If a recurring timer or a tracking script has the page's main thread blocked when the click lands, the click waits until that work is done, and the wait counts as input delay.
INP Radar is a Chrome extension that shows when a page's main thread is blocked, live, in a side panel next to the page. Open any site, leave it alone, and watch: background work shows up as spikes, often on a regular beat. Those are the moments when a user's click would get a poor INP.
When we debug poor INP, the cause is almost always one of these:
- The interaction is expensive. The event handler, and the render it triggers, keep the main thread busy before the page can paint a response.
- The main thread is already blocked. Recurring timers, tracking scripts and other background work run long tasks. The interaction itself may be cheap, but it queues behind them.
You see the first cause as soon as you click and measure. The second is easy to miss, because it only hurts when a click lands during a block. A single test click usually lands between blocks and looks fine.
The panel shows both:
- Radar and heartbeat trace. Both draw the time since the page last rendered a frame. They reveal background blocks without any interaction (cause 2).
- INP and its breakdown. The panel splits the interaction that set INP into input delay, processing and presentation, and names the target element. High input delay means the click waited behind other work (cause 2). High processing or presentation means the interaction itself is expensive (cause 1).
- Event log. Every interaction of 16 ms or more, newest first.
The demo page reproduces both causes. With the extension loaded (see Development), open the page and open the side panel on its tab.
- A recurring timer. A
setIntervalcallback runs a 300 ms long task every 3 s. Don't touch anything: the wedge grows and the trace spikes on every run, at a steady interval. You can change the block length and the interval, or turn the timer off. - An expensive interaction. With the timer off, click the button that runs 250 ms of work. Input delay stays low and almost all of the interaction is processing.
- A cheap interaction, idle and blocked. Click the counter button between blocks and it's fast. Click it during a block and it waits for the long task to finish. That wait is input delay, and it makes a cheap click slow.
The page is a single static file, demo/index.html, with no
dependencies.
To fix an INP problem, you record a trace in the Chrome DevTools Performance panel. INP Radar tells you when to record and when to click:
- Catch a block. Watch the radar until you see the spikes, then record a trace across one. The trace shows the work that blocked the main thread.
- Wait for idle. Watch for a quiet stretch where the trace stays flat, then interact. Nothing else is running, so the trace shows the interaction's own cost.
The panel shows when the main thread is blocked, not which script blocked it. Script attribution, using the Long Animation Frames API, is planned. Until it ships, the DevTools trace is where you find the culprit.
┌──────────── page tab ─────────────┐ ┌──── side panel ────┐
│ content script (page main thread) │ port │ sidepanel/ │
│ • PerformanceObserver('event') │ ─────▶ │ • INP + breakdown │
│ • onINP (web-vitals/attribution) │ │ • radar + pulse │
│ • rAF heartbeat │ │ • event log │
└───────────────────────────────────┘ └────────────────────┘
src/entrypoints/content.tsmeasures inside the page. It buffers INP and event entries, so a panel opened late still shows history. The heartbeat only runs while a panel is connected.src/entrypoints/sidepanel/connects to the active tab withtabs.connect, follows tab switches and navigations, and renders.src/shared/protocol.tsdefines the messages between the two.
The radar comes from Phil Walton's INP demo (source). There it animates on the page's main thread, so it freezes when the page is blocked. Copied into the side panel as-is, it would never freeze, because the panel runs in a different process.
So the content script sends a heartbeat on every page animation frame. The panel spins the sweep on its own clock and draws the wedge as the time since the last heartbeat. When the page is blocked, no frames run and the wedge grows. It turns amber past 200 ms and red past 500 ms (the INP thresholds), then catches up once frames resume.
sidepanel/pulse.ts draws the same signal as an ECG-style strip on a canvas. It
sweeps left to right in sync with the radar. Each column plots the time since
the page's last frame on a square-root scale. Reference lines sit at 50 ms (the
long-task threshold), 200 ms and 500 ms.
A healthy page draws a flat green line. A block draws a spike that climbs for as long as it lasts and drops when frames resume, so you can compare several blocks side by side. A recurring timer shows up as spikes at a steady interval. The readout shows the peak in the current sweep.
Here the page blocks its main thread for 300, 600 and 1500 ms on three clicks:
Built with WXT and vanilla TypeScript.
pnpm install
pnpm dev # launches Chrome with the extension loaded, hot reloading
pnpm build # production build in .output/chrome-mv3
pnpm compile # type-check
pnpm test # unit tests (Vitest); `pnpm test:watch` to re-run on change
pnpm e2e # build, then run the Playwright end-to-end tests
pnpm demo # serve the demo page at http://localhost:5173
pnpm screenshots # build, then re-render the README screenshotsUnit tests sit next to the code as *.test.ts. They run in jsdom with WXT's
Vitest plugin, which provides the @/ aliases and a fake browser API.
The end-to-end tests in e2e/ load the built extension into Playwright's
Chromium (run pnpm exec playwright install chromium once). A test can't open
the side panel, so the tests load sidepanel.html in a tab and then switch to
the test page. The panel follows it, as it would the active tab.
The screenshots in docs/screenshots/ come from tools/screenshots/capture.ts,
which uses the same setup and clicks the test page on a fixed schedule. Joining
the GIF frames needs Python 3 with Pillow.
To load a build manually, open chrome://extensions, enable Developer mode,
click Load unpacked and pick .output/chrome-mv3. Click the toolbar icon to
open the side panel.
The Publish workflow submits a build to the Chrome Web Store. It only runs
by hand: bump version in package.json, then go to Actions → Publish → Run
workflow. Tick dry-run to check the credentials without uploading.
STAGED_PUBLISH holds the version after review until you publish it from the
developer dashboard.
The store API can't create an item, so the first version is uploaded by hand in
the developer dashboard, along
with the listing and privacy details. After that, the workflow needs a
chrome-web-store environment in the repository settings with:
| Name | Kind | Value |
|---|---|---|
CHROME_EXTENSION_ID |
variable | The item ID from the dashboard |
CHROME_PUBLISHER_ID |
variable | The publisher ID from the dashboard's account page |
CHROME_SERVICE_ACCOUNT_CLIENT_EMAIL |
variable | The email of a Google Cloud service account |
CHROME_SERVICE_ACCOUNT_PRIVATE_KEY |
secret | The private_key from that service account's JSON key |
The service account needs the Chrome Web Store API enabled in its Google Cloud project, and its email added to the publisher in the dashboard's account settings.
- Tabs that were open before the extension was installed need a reload.
chrome://pages and the Chrome Web Store can't be measured.- Only the top frame is measured.
- The event log only lists events of 16 ms or more (the Event Timing minimum).
- The panel shows when the main thread is blocked, not which script blocked it (see Using it with a DevTools trace).
Apache-2.0. The radar and side panel styles are adapted from Google's INP demo, also Apache-2.0. See NOTICE.