Instrumenting INP in the Next.js App Router

The App Router streams Server Components, hydrates Client Components progressively, and re-runs handlers across soft route transitions — all of which muddy where an interaction’s latency actually came from. Capturing a single Interaction to Next Paint (INP) number is easy; attributing a slow one to hydration, a streaming boundary, or a client event handler is the hard part, and it is exactly what this page solves as a piece of Framework Performance Instrumentation. The goal: wire Next.js’s built-in useReportWebVitals hook for headline reporting, add a raw PerformanceObserver and the web-vitals attribution build for diagnostics, and ship the result to a RUM endpoint without dropping samples on navigation.

Anatomy of an INP interaction in Next.js A pointer interaction is split into input delay, event processing, and presentation delay, each mapped to a likely Next.js cause such as hydration, a client handler, or a streaming boundary. tap paint Input delay main thread busy hydration / RSC Processing client handler setState / fetch Presentation render + paint layout / commit Attribution fields from the web-vitals attribution build: inputDelay processingDuration presentationDelay INP Good means a p75 interaction latency at or below 200 ms.
An INP interaction splits into three phases; the web-vitals attribution build reports each phase so you can blame the right Next.js layer.

Prerequisites

Before instrumenting, confirm the following are in place:

Requirement Detail
Next.js version 14.x or 15.x using the App Router (app/ directory).
web-vitals npm i web-vitals@^4 for the attribution build (web-vitals/attribution).
RUM endpoint A POST endpoint that accepts JSON beacons. See self-hosted beacon collection for the receiver.
Browser target INP and the attribution build need Chromium 96+. Safari and Firefox lack the event timing type — plan for graceful absence.
Aggregation Report raw samples; compute p75 server-side, never in the client.

INP’s thresholds are fixed by Google: a p75 at or below 200 ms is Good, at or below 500 ms is Needs Improvement, and above 500 ms is Poor. Every snippet below reports raw per-interaction values so your pipeline can derive that p75.

How to instrument INP in the App Router

Step 1 — Add a WebVitals client component with useReportWebVitals

useReportWebVitals (from next/web-vitals) is the supported, framework-aware entry point. It must live in a Client Component, and mounting it once in the root layout covers every route. Create app/_components/WebVitals.tsx:

'use client';

import { useReportWebVitals } from 'next/web-vitals';

const ENDPOINT = '/rum';

function send(payload) {
  const body = JSON.stringify(payload);
  // sendBeacon survives unload and route changes; fetch keepalive is the fallback.
  if (navigator.sendBeacon && navigator.sendBeacon(ENDPOINT, body)) return;
  fetch(ENDPOINT, { body, method: 'POST', keepalive: true });
}

export function WebVitals() {
  useReportWebVitals((metric) => {
    // metric = { name, value, rating, delta, id, navigationType }
    if (metric.name !== 'INP') return;
    send({
      metric: 'INP',
      value: Math.round(metric.value),
      rating: metric.rating, // 'good' | 'needs-improvement' | 'poor'
      id: metric.id,
      navigationType: metric.navigationType, // 'navigate' | 'back-forward' | ...
      path: window.location.pathname,
      ts: Date.now(),
    });
  });
  return null;
}

Mount it in app/layout.tsx so it persists across the whole session:

import { WebVitals } from './_components/WebVitals';

export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <body>
        <WebVitals />
        {children}
      </body>
    </html>
  );
}

Why: useReportWebVitals wraps the web-vitals library and respects the App Router lifecycle, so it finalizes INP on visibilitychange/pagehide for you. Mounting in the root layout means the listener is not torn down on soft navigations — a subtle bug if you place it inside a page.

Step 2 — Capture phase attribution with the web-vitals attribution build

The default callback gives you a single number. To know why it was slow, switch to onINP from web-vitals/attribution, which returns interactionTarget, inputDelay, processingDuration, and presentationDelay. Replace the body of WebVitals.tsx with an effect-driven setup so it runs exactly once:

'use client';

import { useEffect } from 'react';
import { onINP } from 'web-vitals/attribution';

const ENDPOINT = '/rum';

function send(payload) {
  const body = JSON.stringify(payload);
  if (navigator.sendBeacon && navigator.sendBeacon(ENDPOINT, body)) return;
  fetch(ENDPOINT, { body, method: 'POST', keepalive: true });
}

export function WebVitals() {
  useEffect(() => {
    onINP((metric) => {
      const a = metric.attribution;
      send({
        metric: 'INP',
        value: Math.round(metric.value),
        rating: metric.rating,
        id: metric.id,
        // Attribution: which phase dominated, and on which element.
        interactionTarget: a.interactionTarget,   // CSS selector of the element
        interactionType: a.interactionType,        // 'pointer' | 'keyboard'
        inputDelay: Math.round(a.inputDelay),
        processingDuration: Math.round(a.processingDuration),
        presentationDelay: Math.round(a.presentationDelay),
        loadState: a.loadState,                    // e.g. 'dom-interactive'
        path: window.location.pathname,
        ts: Date.now(),
      });
    }, { reportAllChanges: false });
  }, []);
  return null;
}

Why: the three phase durations map cleanly onto Next.js causes. A large inputDelay while loadState is dom-interactive means the interaction landed mid-hydration. A large processingDuration points at a Client Component event handler doing synchronous work. A large presentationDelay points at expensive render/layout after setState. Keep reportAllChanges: false so you get one finalized INP per page lifecycle, matching Google’s algorithm.

Step 3 — Add a raw PerformanceObserver for live diagnostics

For local debugging and per-interaction tracing, attach a raw observer with a durationThreshold. This does not implement Google’s INP algorithm (which discards the worst interactions on busy pages), so use it only for diagnostics — see the web-vitals API implementation reference for the distinction. Add to the same effect:

if (typeof PerformanceObserver !== 'undefined') {
  try {
    const po = new PerformanceObserver((list) => {
      for (const entry of list.getEntries()) {
        const inputDelay = entry.processingStart - entry.startTime;
        const processing = entry.processingEnd - entry.processingStart;
        const presentation = entry.startTime + entry.duration - entry.processingEnd;
        console.debug('[INP]', Math.round(entry.duration), entry.name, {
          target: entry.target?.tagName,
          inputDelay: Math.round(inputDelay),
          processing: Math.round(processing),
          presentation: Math.round(presentation),
        });
      }
    });
    // durationThreshold filters out fast interactions; 40ms is the minimum honored.
    po.observe({ type: 'event', durationThreshold: 40, buffered: true });
    po.observe({ type: 'first-input', buffered: true });
  } catch {
    // 'event' timing unsupported (Safari/Firefox) — attribution build still no-ops safely.
  }
}

Why: type: 'first-input' catches the very first interaction (often during hydration) that event may miss, and buffered: true replays interactions that fired before the observer attached. The durationThreshold keeps console noise down while still surfacing anything that could push p75 toward the 200 ms line.

Step 4 — Break up long handlers with scheduler.yield

When processingDuration dominates, the fix is to yield the main thread inside the handler so the browser can paint. Use scheduler.yield() with a setTimeout fallback — the same pattern covered in breaking up long tasks with scheduler.yield:

'use client';

async function yieldToMain() {
  if (typeof scheduler !== 'undefined' && typeof scheduler.yield === 'function') {
    return scheduler.yield();
  }
  return new Promise((resolve) => setTimeout(resolve, 0));
}

export function FilterButton({ rows }) {
  async function onClick() {
    // Critical visual feedback first — paints fast, keeps presentationDelay low.
    setPending(true);
    await yieldToMain();
    // Heavy work runs after a paint, so it no longer inflates this interaction's INP.
    const filtered = expensiveFilter(rows);
    setResults(filtered);
    setPending(false);
  }
  return <button onClick={onClick}>Filter</button>;
}

Why: yielding after the first visual update lets the interaction’s paint commit, ending the INP measurement window. The expensive filter then runs in a fresh task instead of extending processingDuration.

Verifying it works

Confirm both the local signal and the RUM signal:

  1. DevTools Performance panel. Record a trace, click the instrumented element, and read the Interactions track. It shows the input delay, processing, and presentation segments — these should match the inputDelay/processingDuration/presentationDelay your beacon reports for the same id.
  2. Console. With Step 3 in place, every interaction over 40 ms logs [INP] with its phase breakdown. A handler you just optimized with scheduler.yield should show processing drop sharply.
  3. RUM endpoint. Watch your collector receive metric: "INP" beacons on pagehide. Verify the payload contains a non-null interactionTarget selector and that inputDelay + processingDuration + presentationDelay roughly equals value.
  4. p75 in the dashboard. After samples accumulate, the server-computed p75 INP per route should land at or below 200 ms for Good. Segment by loadState to confirm interactions during hydration are the slow tail.
Source What you see Confirms
Performance panel Interactions track Three-segment bar per interaction Phase durations are real, not estimated
Console [INP] log Per-interaction breakdown live Observer attached, threshold working
RUM beacon on pagehide One finalized INP per page useReportWebVitals/onINP finalized correctly

Edge cases & gotchas

  • RSC streaming boundaries inflate input delay. While a Server Component shell streams in and Suspense boundaries resolve, the main thread is busy committing HTML. Interactions during that window show high inputDelay with loadState near loading/dom-interactive. This is hydration contention, not a slow handler — do not “optimize” the handler; defer or split the hydration instead.
  • Soft route transitions do not reset INP. INP is per-page-load, and an App Router soft navigation (<Link>) does not start a new page lifecycle, so INP keeps accumulating across routes in the same session. A slow interaction on page A still counts after you navigate to page B. Use metric.navigationType and your own path field to attribute the interaction to the route it actually happened on, not the route that was active at pagehide.
  • StrictMode double-invokes effects in dev. In development, React StrictMode mounts effects twice, so a naive onINP/observer setup can register two listeners and double-count. Guard with a module-level flag or rely on useReportWebVitals (which is idempotent); production builds do not double-invoke, but verify your numbers in a production build before trusting them.
  • Safari and Firefox have no event timing. The event/first-input PerformanceObserver types are Chromium-only. The web-vitals attribution build no-ops cleanly there, so you simply collect no INP from those browsers — segment your p75 by browser so missing Safari data does not read as “great INP.”
  • interactionTarget can be a detached selector. If the handler unmounts the element (e.g. closing a modal), the attributed selector may no longer exist in the DOM. Treat it as a label, not a live query.

FAQ

Should I use useReportWebVitals or the web-vitals library directly?

Use useReportWebVitals for headline reporting — it is App Router-aware and idempotent. Add onINP from web-vitals/attribution in a useEffect when you need phase attribution. They can coexist; just deduplicate by metric.id server-side so you do not count the same interaction twice.

Why does my INP stay high after navigating to a faster page?

Because INP is measured per page load and App Router soft navigations do not reset it. The worst interaction from earlier in the session still defines INP at pagehide. Attribute each interaction to its own route using the path captured at interaction time, not at send time.

Does scheduler.yield change the INP number or just where work runs?

It lowers the measured INP when it lets the browser paint the interaction’s visual feedback before heavy work runs. Yield after the critical UI update; yielding before it just delays the paint and does not help presentationDelay.