Skip to main content
Next.jsApril 17, 202611 min read

Measuring Real-World Next.js Performance Beyond Lighthouse

Lighthouse scores and lab metrics tell you what is theoretically possible. Real-user monitoring tells you what is actually happening. This article covers the tools, metrics, and instrumentation setup that give you accurate Next.js performance data in production.

Lighthouse runs against a simulated mid-range Android device on a throttled 3G connection, in a controlled lab environment. It is useful for catching regressions in development. It is not useful for understanding what your actual users experience — which device distribution they use, which network conditions they are on, which routes they visit, and which interactions feel slow to them.

This article covers real-user monitoring (RUM) for Next.js applications: what metrics matter, how to instrument them, and how to operationalise the data in a way that drives actual improvements.

Lab Metrics vs Field Metrics

Metric typeSourceWhat it measuresLimitation
Lab (Lighthouse, WebPageTest)Synthetic test in controlled environmentMaximum possible performanceDoes not reflect real users
Field (CrUX, RUM)Real user browsersActual perceived performanceRequires traffic to have data

Google uses field data from the Chrome User Experience Report (CrUX) for ranking signals — not your Lighthouse score. A page that scores 95 in Lighthouse but serves mostly users on slow connections with large viewports may rank lower than a page scoring 75 that happens to load fast for its actual audience. This distinction matters for every Next.js team running SEO-sensitive content.

The Core Web Vitals Worth Tracking

Three metrics drive the majority of performance decisions for Next.js applications:

Largest Contentful Paint (LCP) — the render time of the largest image or text block visible in the viewport. For most Next.js pages this is the hero image or the first <h1>. Target: under 2.5 seconds at the 75th percentile of real users.

Interaction to Next Paint (INP) — the time from any user interaction (click, keyboard, tap) to the next visual update. Replaced FID as a Core Web Vital in 2024. Target: under 200 milliseconds at the 75th percentile.

Cumulative Layout Shift (CLS) — the sum of unexpected layout shifts during the page's lifetime. Caused by images without explicit dimensions, late-loading fonts, and dynamically injected content. Target: under 0.1.

Instrumenting Core Web Vitals in Next.js

Next.js exposes a built-in hook for reporting Core Web Vitals to any analytics endpoint:

// app/layout.tsx or pages/_app.tsx
export function reportWebVitals(metric: NextWebVitalsMetric) {
  const body = JSON.stringify(metric);
  const url = "/api/vitals";

  if (navigator.sendBeacon) {
    navigator.sendBeacon(url, body);
  } else {
    fetch(url, { body, method: "POST", keepalive: true });
  }
}

The metric object includes name (LCP, INP, CLS, FCP, TTFB), value, rating (good/needs-improvement/poor), and navigationType.

For the API route:

// app/api/vitals/route.ts
export async function POST(request: Request) {
  const metric = await request.json();
  // Forward to your observability backend
  await sendToDatadog(metric); // or Grafana, ClickHouse, etc.
  return new Response(null, { status: 204 });
}

Alternatively, use the web-vitals library directly for more control:

"use client";

import { onLCP, onINP, onCLS } from "web-vitals/attribution";

onLCP((metric) => {
  console.log("LCP element:", metric.attribution.lcpEntry?.element);
  sendToAnalytics(metric);
});

onINP((metric) => {
  console.log("Slow interaction:", metric.attribution.interactionTarget);
  sendToAnalytics(metric);
});

The attribution data is critical for debugging. It tells you which element caused a poor LCP, which interaction triggered a bad INP, and which element shift contributed most to CLS.

Metrics That Lighthouse Does Not Measure

Time to First Byte (TTFB)

TTFB measures the time from the HTTP request being sent to the first byte of the response arriving. It is the combined cost of DNS resolution, TCP connection, TLS handshake, and server processing time. For Next.js applications on Kubernetes, TTFB above 200ms usually points to one of:

  • Server-side rendering executing a slow database query or upstream API call
  • Cold pod startup (container not yet warm)
  • Geographic distance between user and server (requires CDN edge caching or multi-region deployment)

Track TTFB broken down by route. A single slow route caused by a non-indexed database query will average up your 75th percentile TTFB across the board.

Route-Level Timing

The built-in vitals hook reports metrics per page. Aggregate by pathname to find which routes are slowest:

export function reportWebVitals(metric: NextWebVitalsMetric) {
  sendToAnalytics({
    ...metric,
    pathname: window.location.pathname,
    connection: (navigator as any).connection?.effectiveType ?? "unknown",
    deviceMemory: (navigator as any).deviceMemory ?? "unknown",
  });
}

Adding effectiveType and deviceMemory lets you segment — if LCP is 1.8s for desktop users and 4.2s for mobile on 4G, the fix is image optimisation and font loading, not server performance.

Long Tasks

Long tasks block the main thread and directly cause poor INP. Instrument them with the PerformanceObserver API:

"use client";

import { useEffect } from "react";

export function LongTaskMonitor() {
  useEffect(() => {
    if (typeof PerformanceObserver === "undefined") return;

    const observer = new PerformanceObserver((list) => {
      for (const entry of list.getEntries()) {
        if (entry.duration > 50) {
          sendToAnalytics({
            name: "long-task",
            value: entry.duration,
            pathname: window.location.pathname,
          });
        }
      }
    });

    observer.observe({ type: "longtask", buffered: true });
    return () => observer.disconnect();
  }, []);

  return null;
}

Long tasks over 200ms appearing consistently on a specific route indicate a Client Component that is executing too much JavaScript on interaction.

Setting Up a RUM Dashboard

The minimal stack for Next.js RUM in a self-hosted environment:

ComponentPurposeOptions
Collection endpointReceive metrics from browsersNext.js API route, Cloudflare Worker
StoragePersist and query metric dataClickHouse, TimescaleDB, Grafana Cloud
VisualisationPercentile charts, alertingGrafana, Metabase

The key dashboard panels to build:

  1. LCP p50 / p75 / p95 by route — p75 is what Google measures, p95 catches the worst outliers
  2. INP p75 by route — high INP is almost always a JavaScript problem
  3. CLS over time — spikes indicate a recent deploy introduced layout shift
  4. TTFB by route — correlated with server load and query performance
  5. Vitals breakdown by device type and connection — surfaces mobile performance gaps invisible in desktop testing

A simpler starting point is Google Search Console's Core Web Vitals report, which displays CrUX field data for your domain grouped by URL pattern. It lags by 28 days but requires zero instrumentation.

Common Performance Mistakes in Next.js Production

Images without width and height cause CLS. Next.js <Image> requires these props and reserves space before the image loads. Using a raw <img> without dimensions in a Server Component bypasses this protection.

Loading third-party scripts in <head> without strategy="lazyOnload" blocks the parser and inflates LCP. Use Next.js <Script strategy="lazyOnload"> for analytics, chat widgets, and anything not needed for the initial render.

Importing large libraries in Client Components increases the JavaScript bundle evaluated on each interaction, directly raising INP. Audit bundle size with next build --profile and @next/bundle-analyzer.

Not pre-sizing font fallbacks causes CLS as the custom font loads and glyphs reflow. Use size-adjust, ascent-override, and descent-override in your fallback @font-face declarations, or use Next.js built-in font optimisation (next/font) which handles this automatically.

Alerting on Performance Regressions

Performance monitoring without alerting is archaeology. Set thresholds at the 75th percentile and alert when they are breached for more than a 30-minute window (to avoid alerting on transient spikes):

  • LCP p75 > 3.0s for any high-traffic route
  • INP p75 > 300ms for any route with frequent interaction
  • CLS > 0.15 for any route (likely a deploy regression)
  • TTFB p75 > 500ms for server-rendered routes

Correlate alerts with deploys. Most performance regressions are introduced by a specific commit — surfacing the correlation shortens the debugging loop from hours to minutes.

Private DevOps instruments RUM, long-task monitoring, and Grafana dashboards as part of every Next.js production deployment on Kubernetes. The goal is that performance regressions are caught in the monitoring layer before they are reported by users.

Need help with this?

Our team handles this kind of work daily. Let us take care of your infrastructure.