
Measuring Real-User Performance in Next.js with useReportWebVitals
Lighthouse tells you how your page performs on one simulated device, on one simulated network, on one run. Your users are on hundreds of different phones, flaky connections, and browsers with twelve extensions installed. The numbers that matter for both user experience and search ranking are the ones measured on their devices, and you can't get those from a lab test.
Next.js ships a small hook, useReportWebVitals, that hands you Core Web Vitals measurements from every real page load. What it doesn't do is store or analyze them. That part is up to you. In this post I'll set up the hook properly in the App Router, send the data to your own Route Handler without slowing anything down, store it, and query it in a way that produces numbers you can actually act on. I'll also cover the gotchas: duplicate reports, client-side navigations, sampling, and what each field in the metric object means.
Lab Data vs Field Data
It's worth being clear about why you'd bother with this when Lighthouse is free.
| Lab data (Lighthouse, DevTools) | Field data (real-user monitoring) | |
|---|---|---|
| Where it runs | Your machine or CI, simulated device | Your visitors' browsers |
| Variety | One configuration per run | Every device, network, and browser your users have |
| Interactions | None or scripted | Real clicks, taps, and typing |
| Best for | Debugging, catching regressions before deploy | Knowing what users actually experience |
| INP | Can't measure it (no real interactions) | Yes |
Both are useful. Lab data tells you why something is slow. Field data tells you whether it's slow for the people using your site, and how often. Google's ranking signals and the Chrome UX Report are based on field data, at the 75th percentile.
The Metrics You'll Receive
useReportWebVitals reports the following metrics. The thresholds are Google's published boundaries for "good" and "poor", measured at the 75th percentile of page loads.
| Metric | Measures | Good | Poor |
|---|---|---|---|
| LCP (Largest Contentful Paint) | When the main content appears | 2.5 s or less | over 4 s |
| INP (Interaction to Next Paint) | How quickly the page responds to input | 200 ms or less | over 500 ms |
| CLS (Cumulative Layout Shift) | How much the layout jumps around | 0.1 or less | over 0.25 |
| FCP (First Contentful Paint) | When anything first appears | 1.8 s or less | over 3 s |
| TTFB (Time to First Byte) | Server and network response time | 0.8 s or less | over 1.8 s |
LCP, INP, and CLS are the three Core Web Vitals. FCP and TTFB are diagnostic: they help explain a bad LCP. The hook also still reports FID (First Input Delay), which was replaced by INP as a Core Web Vital in 2024. You can ignore it or filter it out.
Setting Up the Hook in the App Router
useReportWebVitals is a client hook imported from next/web-vitals. Because it needs "use client", the cleanest approach is a tiny component that renders nothing, mounted once in the root layout. That keeps the client boundary to this one component instead of turning your whole layout into a Client Component.
// app/_components/web-vitals.tsx
"use client";
import { useReportWebVitals } from "next/web-vitals";
type ReportFn = Parameters<typeof useReportWebVitals>[0];
const report: ReportFn = (metric) => {
console.log(metric.name, metric.value, metric.rating);
};
export function WebVitals() {
useReportWebVitals(report);
return null;
}
// app/layout.tsx
import type { ReactNode } from "react";
import { WebVitals } from "./_components/web-vitals";
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body>
<WebVitals />
{children}
</body>
</html>
);
}
Two details matter here.
First, the callback is defined at module level, outside the component. The hook registers its listeners in an effect that depends on the callback, so if you pass an inline arrow function, every re-render creates a new function, the effect re-runs, and you get duplicate reports. A module-level function has a stable reference for the lifetime of the page.
Second, the ReportFn type is derived from the hook itself. That gives you a fully typed metric without importing from an internal path.
Open the page, interact with it, then switch to another tab. You'll see FCP, TTFB, and LCP logged early, and CLS and INP logged when the page is hidden. That timing is intentional: CLS and INP can keep changing for as long as the page is open, so they're reported when the user leaves.
What's in the Metric Object
Each callback receives one metric with these fields:
| Field | Meaning |
|---|---|
name | "LCP", "INP", "CLS", "FCP", "TTFB", or "FID" |
value | The current value. Milliseconds for timing metrics, a unitless score for CLS. |
delta | Change since the last report of this metric on this page load. |
id | Unique ID for this metric on this page load. |
rating | "good", "needs-improvement", or "poor" based on the thresholds above. |
navigationType | How the page was loaded, e.g. "navigate", "reload", "back-forward", "back-forward-cache", "prerender". |
entries | The underlying PerformanceEntry objects, useful for debugging. |
id and delta matter when a metric is reported more than once on the same page load. If you store every report, you can either keep only the latest report per id, or sum the delta values per id. Both give you the final value. Mixing approaches gives you double counting.
navigationType is underrated. Back/forward cache restores are nearly instant, and prerendered pages can report an LCP close to zero. If you average them together with cold loads, you'll get numbers that look better than what first-time visitors see. Store it and segment by it.
Sending Metrics to Your Own Endpoint
Logging to the console proves the hook works. For real data you need to send it somewhere. The two rules:
- Don't block anything. Reporting should never compete with the page for bandwidth or main-thread time.
- Survive page unload. CLS and INP are often reported as the user leaves, and a normal
fetchmay be cancelled.
navigator.sendBeacon solves both. It queues a small POST that the browser delivers even after the page is gone. When it's unavailable, fetch with keepalive: true does the same job.
Here's a fuller client component that batches metrics, adds context, and flushes when the page is hidden:
// app/_components/web-vitals.tsx
"use client";
import { useReportWebVitals } from "next/web-vitals";
type ReportFn = Parameters<typeof useReportWebVitals>[0];
type Metric = Parameters<ReportFn>[0];
const ENDPOINT = "/api/vitals";
const SAMPLE_RATE = 1; // set to e.g. 0.1 to keep 10% of page loads
// Decide once per page load whether this visit is sampled.
const sampled = typeof window !== "undefined" && Math.random() < SAMPLE_RATE;
// The path the user landed on. Web vitals describe the initial page load,
// so attribute every metric to this path even if the user navigates later.
const landingPath =
typeof window !== "undefined" ? window.location.pathname : "";
let queue: Record<string, unknown>[] = [];
function flush() {
if (queue.length === 0) return;
const body = JSON.stringify(queue);
queue = [];
const blob = new Blob([body], { type: "application/json" });
if (navigator.sendBeacon?.(ENDPOINT, blob)) return;
fetch(ENDPOINT, {
method: "POST",
body,
headers: { "Content-Type": "application/json" },
keepalive: true,
}).catch(() => {});
}
if (typeof window !== "undefined") {
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden") flush();
});
}
const report: ReportFn = (metric: Metric) => {
if (!sampled || metric.name === "FID") return;
queue.push({
id: metric.id,
name: metric.name,
value: metric.value,
rating: metric.rating,
navigationType: metric.navigationType,
path: landingPath,
viewport: window.innerWidth < 768 ? "mobile" : "desktop",
});
};
export function WebVitals() {
useReportWebVitals(report);
return null;
}
What this does:
- Sampling happens once per page load, not per metric, so you always get a complete set of metrics for the visits you keep. On a high-traffic site, 10% is plenty for stable percentiles.
- Batching collects metrics in memory and sends them in one request when the tab is hidden. That covers closing the tab, switching apps on mobile, and navigating to another site. Mobile browsers don't reliably fire
unloadorbeforeunload, which is whyvisibilitychangeis the event to use. - Context is kept minimal: the landing path and a coarse device class. Don't send user IDs, full URLs with query strings, or anything else that could identify a person unless you've thought through your privacy obligations.
landingPathis captured when the module first runs, which is on the initial page load. More on why below.
Sending a Blob instead of a string
sendBeacon with a plain string sends Content-Type: text/plain. Wrapping the JSON in a Blob with type: "application/json" lets your handler parse it with request.json() like any other JSON request. Both work; the Blob is just tidier on the server side.
Receiving Metrics in a Route Handler
On the server, a Route Handler accepts the batch, validates it, and stores it. Validation matters because this endpoint is public: anyone can POST to it.
// app/api/vitals/route.ts
const METRIC_NAMES = new Set(["LCP", "INP", "CLS", "FCP", "TTFB"]);
type VitalReport = {
id: string;
name: string;
value: number;
rating: string;
navigationType: string;
path: string;
viewport: string;
};
function isValid(item: unknown): item is VitalReport {
if (typeof item !== "object" || item === null) return false;
const r = item as Record<string, unknown>;
return (
typeof r.id === "string" &&
typeof r.name === "string" &&
METRIC_NAMES.has(r.name) &&
typeof r.value === "number" &&
Number.isFinite(r.value) &&
r.value >= 0 &&
typeof r.path === "string" &&
r.path.length < 300
);
}
export async function POST(request: Request) {
let payload: unknown;
try {
payload = await request.json();
} catch {
return new Response(null, { status: 400 });
}
if (!Array.isArray(payload) || payload.length > 20) {
return new Response(null, { status: 400 });
}
const reports = payload.filter(isValid);
for (const r of reports) {
// Structured log line. Ship these to your log pipeline,
// or replace this with a database insert (see below).
console.log(JSON.stringify({ type: "web-vital", ts: Date.now(), ...r }));
}
return new Response(null, { status: 204 });
}
The handler rejects anything that isn't a small array of well-formed metrics, then writes each one as a structured JSON log line. Many hosting platforms and log services can query JSON logs directly, which is often enough to get started. It returns 204 No Content because the browser never reads the response.
If you protect routes with proxy.ts, make sure /api/vitals isn't behind an auth check, or anonymous visitors' metrics will be redirected to your login page and lost.
Storing in Postgres
For anything beyond a quick look, store the data in a table. Here's a minimal schema:
CREATE TABLE web_vitals (
id text NOT NULL,
name text NOT NULL,
value double precision NOT NULL,
rating text,
navigation_type text,
path text NOT NULL,
viewport text,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (id, name)
);
The primary key on (id, name) means a metric reported twice on the same page load can be upserted, so you always keep the latest value. Swap the logging loop for an insert using the pg package:
// lib/db.ts
import { Pool } from "pg";
export const pool = new Pool({ connectionString: process.env.DATABASE_URL });
// in app/api/vitals/route.ts, replace the console.log loop
import { pool } from "@/lib/db";
// ...
for (const r of reports) {
await pool.query(
`INSERT INTO web_vitals (id, name, value, rating, navigation_type, path, viewport)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (id, name) DO UPDATE SET value = EXCLUDED.value, rating = EXCLUDED.rating`,
[r.id, r.name, r.value, r.rating, r.navigationType, r.path, r.viewport],
);
}
Turning Raw Data Into Useful Numbers
Averages are misleading for performance data, because a few extremely slow loads drag the mean up while most users are fine, or a pile of instant cache restores drags it down. Use percentiles, and use the 75th percentile to match how Google evaluates Core Web Vitals.
SELECT
path,
name,
count(*) AS samples,
round(percentile_cont(0.75) WITHIN GROUP (ORDER BY value)::numeric, 3) AS p75
FROM web_vitals
WHERE created_at > now() - interval '7 days'
AND navigation_type IN ('navigate', 'reload')
AND viewport = 'mobile'
GROUP BY path, name
HAVING count(*) >= 50
ORDER BY name, p75 DESC;
This gives you p75 per page and metric for mobile users over the past week, excluding back/forward cache restores and prerenders, and skipping pages with too few samples to be meaningful. Sorting by p75 descending puts your worst pages at the top, which is where to start.
A few habits that make the data more useful:
- Segment by device class. Mobile and desktop numbers are usually so different that combining them hides problems.
- Look at the share of "poor" ratings, not only p75. A page can have a fine p75 but a long tail of terrible loads.
- Compare before and after deploys. Add a release identifier (for example, a
NEXT_PUBLIC_env variable set at build time) to each report so you can tie regressions to a specific change.
Debugging With entries
When a page has a poor LCP, the next question is which element was the LCP. The entries array holds the browser's LargestContentfulPaint entries, and the last one points at the element:
// app/_components/web-vitals.tsx (development helper)
const report: ReportFn = (metric) => {
if (process.env.NODE_ENV !== "production" && metric.name === "LCP") {
const last = metric.entries.at(-1) as
(PerformanceEntry & { element?: Element | null }) | undefined;
console.log("LCP", Math.round(metric.value), "ms", last?.element);
}
};
In the console you'll see the actual DOM node, often a hero image that isn't prioritized or a heading waiting on a web font. Similarly, CLS entries are LayoutShift entries with a sources list of the elements that moved. Keep these helpers to development; sending DOM details from production adds weight and can leak page content.
Once you know the culprit, the fixes are usually the ones covered in improving Core Web Vitals in a Next.js application: loading="eager" or fetchPriority="high" on the LCP image, next/font to avoid font-related shifts, and moving work off the main thread for INP.
Gotchas
Client-side navigations aren't new page loads
Web vitals as defined today describe the initial, full page load. When a user clicks a Link and Next.js renders the next route client-side, the browser doesn't produce a new LCP or FCP for that route. CLS and INP keep accumulating across the session and are reported when the page is hidden.
That's why the example attributes everything to landingPath rather than the current URL at report time. If you used the current path, CLS and INP from a session that started on / and ended on /checkout would be blamed on /checkout. Attributing to the landing page keeps the data consistent with how Chrome's own field data works.
Development numbers are meaningless
In next dev, code is unminified, compiled on demand, and React runs extra checks. Your metrics will look much worse than production. Only collect and analyze data from production builds, and if you want to be strict, skip sending entirely when process.env.NODE_ENV !== "production".
Not every browser reports every metric
Some metrics depend on browser APIs that aren't available everywhere. Your dataset will be weighted toward browsers that support them. That's fine for spotting trends and regressions, but keep it in mind before drawing conclusions about a specific browser's users.
Ad blockers
Some blockers drop requests to paths that look like analytics. A neutral path like /api/vitals is less likely to be blocked than /analytics or /track, but you should expect some loss either way.
Alternatives to Rolling Your Own
If you don't want to run storage and queries yourself:
- Your existing analytics. The Next.js docs show sending metrics to Google Analytics with
gtag, usingmetric.idas the event label so you can build distributions later. Remember to multiply CLS by 1000 and round, since GA event values must be integers. - Your hosting provider's analytics. Many platforms offer built-in speed insights that collect the same metrics with no code.
instrumentation-client.ts. If you'd rather use theweb-vitalspackage directly (for example, to get its attribution build), you can install it and initialize it ininstrumentation-client.ts, which runs before your app's frontend code. That bypasses the hook entirely.
Conclusion
useReportWebVitals gives you the raw material for real-user monitoring with almost no code: mount a small client component in the root layout with a stable callback, and every page load reports LCP, INP, CLS, FCP, and TTFB. The work is in what comes next. Batch and send with sendBeacon when the page is hidden, validate on a Route Handler, store with the metric id so repeated reports don't double count, attribute to the landing path, and look at the 75th percentile by page and device. With that in place, you'll know which pages are slow for real users, and you'll see whether your fixes actually helped.


