Interaction to Next Paint, for React
react-inp-blame
When a click, tap or key press in your React app is slow, this names the component or the handler behind it and says where the time went.
npm install react-inp-blame
Works with
- web-vitals
- Sentry
- Google Analytics 4
- OpenTelemetry
- Honeycomb
- Elastic
- Datadog
- Embrace
- Grafana Faro
Where the time went, in plain words
For a slow click, tap or key press in a React app, it shows where the time went and names the component or handler.
Names the code
The component or handler most likely behind the slow interaction, in your own names.
Splits the time
Waiting, working and updating the screen, so you know which of the three to fix.
Sees what INP misses
A second React render that landed after the paint is joined to the click. INP doesn't count it, and people still wait for it.
Fits the tools you have
It rides on web-vitals attribution, or on the INP event OpenTelemetry already sends.
Says "most likely"
Every blame is marked measured or inferred, with that confidence shown in the sentence.
A development tool first
The Vite plugin and the Next.js wrapper leave it out of production builds unless you turn it on.
A badge in the corner, a panel when you need it
A badge you can ignore
A small dark badge sits in the corner and has no number until you interact. Then it shows the page's INP so far in milliseconds: green at 200 or under, amber up to 500, red above.
A panel of the slow ones
Click the badge for the recent slow interactions, newest first. Each row names the component or handler and splits the time into waiting, working and updating the screen.
A row that explains itself
Click a row for the whole explanation in plain sentences, including what React rendered after the paint and which components it was.
The same report in code
Everything on the panel is also a report your code can read with
onInteraction, so it can go to the tools you already use.
With web-vitals
The React side added to web-vitals' INP attribution, sent to Sentry or to GA4. One option on onINP, and the component path lands where web-vitals puts a CSS selector.
import { onINP } from 'web-vitals/attribution';
import { generateTarget } from 'react-inp-blame/web-vitals';
onINP(send, { generateTarget });
// attribution.interactionTarget: 'ProfilePage > PhotoTile (button.tile)'import * as Sentry from '@sentry/browser'; // or @sentry/react, @sentry/nextjs
import { onINP } from 'web-vitals/attribution';
import { attributeINP, generateTarget } from 'react-inp-blame/web-vitals';
const sent = new Set<string>();
onINP((metric) => {
if (sent.has(metric.id)) return; // the same page view, reported again
sent.add(metric.id);
const { interactionTarget, react } = attributeINP(metric);
Sentry.metrics.distribution('inp', metric.value, {
unit: 'millisecond',
attributes: {
rating: metric.rating,
target: interactionTarget, // 'ProfilePage > PhotoTile (button.tile)'
'blame.kind': react?.blame.kind, // 'render', 'handler', 'layout', 'waiting', ...
'blame.name': react?.blame.name ?? undefined,
'blame.confidence': react?.blame.confidence, // 'measured' or 'inferred'
'react.build': react?.reactBuild ?? undefined, // 'development' reads high, filter it out
},
});
}, { generateTarget });import { onINP } from 'web-vitals/attribution';
import { attributeINP, generateTarget } from 'react-inp-blame/web-vitals';
onINP((metric) => {
const { interactionTarget, react } = attributeINP(metric);
// gtag() is the global the Google tag defines, typed by @types/gtag.js
gtag('event', metric.name, {
value: metric.delta, // delta, so the values can be summed
metric_id: metric.id,
metric_value: metric.value,
metric_delta: metric.delta,
page_location: metric.navigationURL,
debug_target: interactionTarget, // 'ProfilePage > PhotoTile (button.tile)'
debug_blame_kind: react?.blame.kind,
debug_blame_name: react?.blame.name ?? undefined,
debug_blame_confidence: react?.blame.confidence, // 'measured' or 'inferred'
debug_react_build: react?.reactBuild ?? undefined, // 'development' reads high, filter it out
});
}, { generateTarget });It needs web-vitals 5.1 or later. generateTarget needs no install() and never throws. attributeINP(metric) adds a react field with the blame, its confidence, the handler and the hot path. Sentry metrics need Sentry 10.25 or later.
Keep component names in production with the displayName transform from the Next.js wrapper, the Vite plugin or the loader.
With OpenTelemetry experimental
The blame added to the INP event, in OpenTelemetry, Honeycomb, Elastic. It lands as react_inp_blame.* attributes, so you can group and filter on it beside the INP value. Export names and attribute names can change in any minor release, 1.x included.
import { withInpBlame } from "react-inp-blame/next";
export default withInpBlame(
{
/* your config */
},
{
enabled: true, // production builds too
runtime: { sampleRate: 0.1 }, // on one page load in ten
},
);// src/telemetry.ts
import { logs } from "@opentelemetry/api-logs";
import { WebVitalsInstrumentation } from "@opentelemetry/browser-instrumentation/experimental/web-vitals";
import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-http";
import { registerInstrumentations } from "@opentelemetry/instrumentation";
import {
BatchLogRecordProcessor,
LoggerProvider,
} from "@opentelemetry/sdk-logs";
import { InpBlameLogRecordProcessor } from "react-inp-blame/otel";
const loggerProvider = new LoggerProvider({
processors: [
new InpBlameLogRecordProcessor(), // first, so every processor after it sends the attributes
new BatchLogRecordProcessor({
exporter: new OTLPLogExporter({
url: "https://collector.example.com/v1/logs",
}),
}),
],
});
logs.setGlobalLoggerProvider(loggerProvider);
registerInstrumentations({
instrumentations: [new WebVitalsInstrumentation()],
loggerProvider,
});// src/honeycomb.ts
import { HoneycombWebSDK } from "@honeycombio/opentelemetry-web";
import { inpBlameAttributes } from "react-inp-blame/otel";
const sdk = new HoneycombWebSDK({
apiKey: "your-ingest-key",
serviceName: "shop",
webVitalsInstrumentationConfig: {
inp: {
applyCustomAttributes: (vital, span) =>
span.setAttributes(inpBlameAttributes(vital)),
},
},
});
sdk.start();// src/elastic.ts
import { startBrowserSdk } from "@elastic/opentelemetry-browser";
import { inpBlameAttributes } from "react-inp-blame/otel";
startBrowserSdk({
serviceName: "shop",
exportConfig: { url: "https://collector.example.com" },
instrumentations: {
"web-vitals": {
applyCustomLogRecordData: (record) => {
record.attributes = {
...record.attributes,
...inpBlameAttributes(record),
};
},
},
},
});Enable the library in production and set a sample rate. Install it before the telemetry setup, and put the processor first in the list.
Embrace and Grafana Faro have setups too. The entry sends component, handler and script names and numbers, never an element's text, target.label or a verdict sentence.
What you see
This is the library's real panel, for a click on login in the demo. The report shows a 400 ms interaction, about 399 ms of it in the handler, and a React render that landed after the screen updated. INP didn't count that later render.
Without the timeline it is easy to investigate the render and miss the handler. The panel marks inferred blame as most likely. A profiling build of React gives render times.
400 ms click on button "Log in" in SignInPage. The click handler handleLogin ran for about 399 ms; React's own render took under 1 ms. A second React render landed 295 ms after the screen updated: 81 ms rendering 260 components from SignInDemo down, 241 of them inside ProfilePage, mostly PhotoTile (240 of the 260, 72 ms).Page INP so far, from click on "login" · 25 interactions
most likely onClick in SignInPage in the handler
then ProfilePage re-rendered after the paint · PhotoTile ×240
The onClick handler most likely took the 399 ms: React re-rendered only 2 components. A profiling build of React would give exact numbers.
A second React render landed 294 ms after the screen updated: re-rendering 256 components inside ProfilePage, mostly PhotoTile (240 of them). INP doesn't count it, but people still wait for it.
Rendered after the paint · 256 components
- PhotoTile240 rendered
- Pill4 rendered
- PhaseBar3 rendered
- Entry3 rendered
How it works
It reads what the browser and React already expose, and joins the two. No component needs to change.
It listens to the browser
Event Timing gives each interaction's duration and its three phases. On Chromium 123 and later, Long Animation Frames adds which scripts ran and how much forced layout they caused.
It reads React's fiber tree
Through the hook React exposes for developer tools, it walks each commit and counts what rendered. It checks the shapes it reads and stops cleanly outside react-dom 17 to 19, with one warning.
It joins them and picks a blame
The blame is a render, a handler, a layout, a script, a hydration, waiting or painting. Each is marked measured or inferred, because a production build of React records no render times.
It hands you the report
You get the badge and panel, a report in code, web-vitals attribution, or attributes on the OpenTelemetry INP event.
Install
Add the package, then the setup for your framework, and restart the dev server. Both setups are development-only by default.
npm install react-inp-blame// vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { inpBlame } from "react-inp-blame/vite";
export default defineConfig({
plugins: [react(), inpBlame({ runtime: { overlay: true } })],
});// next.config.ts
import type { NextConfig } from "next";
import { withInpBlame } from "react-inp-blame/next";
const nextConfig: NextConfig = { /* config options here */
};
// Your config first, this library's options second.
export default withInpBlame(nextConfig, { runtime: { overlay: true } });
// instrumentation-client.ts, Next.js 15.3 to 16.2 only
export { onRouterTransitionStart } from "react-inp-blame/next-client";Production needs an explicit opt-in. React Router, Remix, TanStack Start, Astro and webpack have setups of their own in the install guide.
Docs
Questions
Does it change my app?
The Vite plugin and the Next.js wrapper add an install script that runs before React, and a transform that keeps component names in production. Your components are not edited. With enabled left at its default, neither plugin adds anything to a production build.
Should I use the web-vitals route or the OpenTelemetry one?
If you already send web-vitals to Sentry or GA4, use web-vitals: it is one option on onINP. If your INP event goes through OpenTelemetry's web vitals instrumentation, Honeycomb, Elastic, Embrace or Grafana Faro, use OpenTelemetry. That route is experimental, so its names can change in a minor release.
Can I run it in production?
It is a development tool first, and production is optional. Turn it on with enabled: true and sample it with sampleRate. Its own time was 1.3 to 3.4 ms per interaction at the median on two demo scenarios, and what loads with the page is 35.8 KB gzip.
Does it send what people type?
No. target.label never reads a form field's value. The OpenTelemetry entry sends component, handler and script names and numbers only. A label that your own code builds from user data would go out as written, so check the ids and test ids you set.
Which browsers and versions does it support?
Works in Chromium 96+, Firefox 144+ and Safari 26.2+, using Event Timing's interactionId. It reads react-dom 17, 18 and 19, runs with Next.js 14.2 or later, and its CI covers Vite 6.4, 7.3 and 8.3 and a build on Vite 5.
Find the code behind your slowest click
Add it to web-vitals, or to the INP event OpenTelemetry already sends.