paulserban.eu

Writing Edition

Paul Serban

Writing, snippets & book notes

March 02, 2024

Optimizing Core Web Vitals in React and Next.js: A Fullstack Engineer's Playbook

How to diagnose, fix, and prevent LCP, INP, and CLS regressions across the rendering pipeline, from server to browser

Introduction

Core Web Vitals stopped being a marketing checkbox the moment Google folded them into search ranking signals and product teams started tying them to conversion and retention metrics. For a fullstack engineer working in React and Next.js, this is not a frontend-only concern. Largest Contentful Paint (LCP), Interaction to Next Paint (INP), and Cumulative Layout Shift (CLS) are the visible symptoms of decisions made across the entire stack: how data is fetched, how components are rendered, how bundles are split, and how the server responds under load.

Next.js occupies an unusual position here. It gives you rendering strategies - static generation, server-side rendering, streaming, and client-side hydration - that can each produce wildly different Web Vitals outcomes for the same UI. That flexibility is a gift and a trap. Teams frequently ship a Next.js app assuming the framework "handles performance" by default, then discover in the field that a single unoptimized image, a blocking third-party script, or a poorly memoized component tree is quietly tanking their scores. This article works through the mechanics of each metric, the tools that expose them, and the concrete engineering patterns that move the needle, with an emphasis on what changes when React and Next.js are the substrate rather than a generic SPA.

Context: What Core Web Vitals Actually Measure

Core Web Vitals, as defined by the Chrome team and documented at web.dev, are a subset of the broader Web Vitals initiative chosen because they capture distinct, measurable dimensions of user-perceived performance. LCP measures loading performance by timing when the largest visible content element renders. INP, which replaced First Input Delay (FID) as a Core Web Vital in March 2024, measures overall responsiveness by sampling the latency of all interactions during a page's lifecycle, not just the first one. CLS measures visual stability by quantifying unexpected layout shifts that occur after initial render.

The thresholds matter because they define "good," "needs improvement," and "poor" buckets used in Google's ranking and in tools like PageSpeed Insights. As of current guidance, a "good" LCP is under 2.5 seconds, a "good" INP is under 200 milliseconds, and a "good" CLS is under 0.1. These are measured at the 75th percentile of page loads, segmented by mobile and desktop, which is an important detail engineers often miss: optimizing your median load time does nothing for a metric graded at p75. A page that's fast for most users but has a long tail of slow loads on older devices or poor networks will still fail the assessment.

The second piece of context that matters for React specifically is that these metrics are measured in the real browser, on real user devices, via the Chrome User Experience Report (CrUX) and the web-vitals JavaScript library, not in a synthetic lab environment alone. Lab tools like Lighthouse are useful for regression testing in CI, but they run on a fixed, often powerful machine with a simulated network throttle. Field data, captured through Real User Monitoring (RUM), is what actually determines your Search Console scores. A fullstack engineer needs both: lab tests to catch regressions before merge, and field data to know what's actually happening for users on a three-year-old Android phone on a spotty LTE connection.

Deep Technical Explanation

LCP: Loading Performance in a Hybrid Rendering World

LCP is the sum of several sequential costs: time to first byte (TTFB), resource load delay, resource load time, and element render delay. In a Next.js app, each of these maps to a specific architectural decision. TTFB is dominated by your rendering strategy - Static Site Generation (SSG) and Incremental Static Regeneration (ISR) serve pre-built HTML from a CDN edge, often achieving sub-100ms TTFB, while Server-Side Rendering (SSR) pays the cost of a server round trip, database query, and render-to-string on every request unless you add caching.

The App Router's React Server Components (RSC) model, introduced in Next.js 13 and stabilized through subsequent releases, changes this calculus again. Server Components can fetch data directly in the component tree without a client-side waterfall, and Next.js can stream the resulting HTML via Suspense boundaries so the browser can start painting above-the-fold content before slower data dependencies resolve. This is a meaningful architectural shift: instead of shipping a JSON payload and having the client fetch-then-render, the server does the data fetching and initial render, and only the interactive pieces hydrate on the client.

The remaining LCP costs - resource load time and render delay - are typically dominated by the LCP element itself, which in most consumer apps is a hero image or a large block of text. Image optimization is the highest-leverage lever here. The next/image component handles responsive sizing, modern format negotiation (WebP/AVIF), lazy loading for offscreen images, and priority hints for above-the-fold images, but it only helps if configured correctly. A hero image without the priority prop will be lazy-loaded like everything else, which directly delays your LCP candidate.

INP: Responsiveness Beyond the First Click

INP is the more subtle of the three metrics because it isn't about a single event but about the worst-case (roughly 98th percentile) interaction latency across the page's entire lifespan. An interaction's latency has three components: input delay (time before the event handler runs, often blocked by the main thread being busy), processing time (the handler and any synchronous work it triggers), and presentation delay (time for the browser to paint the next frame).

In React applications, INP problems usually trace to one of a few patterns: large synchronous state updates that re-render expensive subtrees, unmemoized components re-rendering on every parent update, third-party scripts monopolizing the main thread, and hydration itself blocking interactivity on initial load. That last one is specific to SSR/SSG frameworks: if your JavaScript bundle is large, the browser can display the server-rendered HTML (contributing to a good LCP) while remaining unresponsive to clicks until hydration completes, sometimes called the "uncanny valley" of SSR - a page that looks ready but isn't.

React 18's concurrent features are the primary tool for addressing this. startTransition lets you mark state updates as non-urgent so React can interrupt them to handle a more urgent update, like a keystroke or a click, keeping the main thread responsive. useDeferredValue gives you a similar effect for derived values, letting an expensive re-render lag a beat behind the input that triggered it. Combined with selective hydration in Next.js's App Router, which hydrates components as they become visible or interacted with rather than all at once, you can substantially reduce the window during which the page appears interactive but isn't.

// Before: a search-as-you-type filter that blocks the input on every keystroke
function ProductSearch({ products }: { products: Product[] }) {
  const [query, setQuery] = useState('');
  const filtered = products.filter(p =>
    p.name.toLowerCase().includes(query.toLowerCase())
  );

  return (
    <div>
      <input
        value={query}
        onChange={(e) => setQuery(e.target.value)} // blocks on large lists
      />
      <ResultsList items={filtered} />
    </div>
  );
}

// After: urgent input update stays responsive; expensive filtering is deferred
function ProductSearch({ products }: { products: Product[] }) {
  const [query, setQuery] = useState('');
  const [isPending, startTransition] = useTransition();

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const value = e.target.value;
    setQuery(value); // urgent: keep the input field responsive
    startTransition(() => {
      // non-urgent: React can interrupt this if the user types again
      setDeferredQuery(value);
    });
  };

  const [deferredQuery, setDeferredQuery] = useState('');
  const filtered = useMemo(
    () => products.filter(p =>
      p.name.toLowerCase().includes(deferredQuery.toLowerCase())
    ),
    [products, deferredQuery]
  );

  return (
    <div style={{ opacity: isPending ? 0.7 : 1 }}>
      <input value={query} onChange={handleChange} />
      <ResultsList items={filtered} />
    </div>
  );
}

CLS: Stability as a Layout Contract

CLS is calculated from "impact fraction" (how much of the viewport shifted) multiplied by "distance fraction" (how far it moved), summed across unexpected shifts during the page's lifetime. Unlike LCP and INP, CLS is almost entirely preventable through disciplined layout practices rather than performance tuning per se.

The most common sources in React and Next.js apps are images and embeds rendered without explicit dimensions, web fonts causing a Flash of Unstyled Text (FOUT) or Flash of Invisible Text (FOIT) that reflows the page, dynamically injected content (banners, cookie notices, ads) that pushes existing content down, and client-side data fetching that renders a skeleton of one size and then swaps in real content of another size. next/font, introduced to replace manual Google Fonts imports, automatically self-hosts font files and applies size-adjust descriptors to minimize layout shift from font swapping, which is a meaningful improvement over the older <link>-based approach that had no such guarantees.

Implementation and Practical Examples

Instrumentation should come before optimization. Without RUM data, you're optimizing based on Lighthouse scores that may not reflect what your actual user base experiences. The web-vitals library, maintained by the Chrome team, is the standard way to capture field metrics and ship them to your analytics backend.

// lib/vitals.ts
import { onLCP, onINP, onCLS, type Metric } from 'web-vitals';

function sendToAnalytics(metric: Metric) {
  const body = JSON.stringify({
    name: metric.name,
    value: metric.value,
    rating: metric.rating, // 'good' | 'needs-improvement' | 'poor'
    id: metric.id,
    navigationType: metric.navigationType,
  });

  // sendBeacon avoids delaying page unload and survives navigation
  if (navigator.sendBeacon) {
    navigator.sendBeacon('/api/vitals', body);
  } else {
    fetch('/api/vitals', { body, method: 'POST', keepalive: true });
  }
}

export function reportWebVitals() {
  onLCP(sendToAnalytics);
  onINP(sendToAnalytics);
  onCLS(sendToAnalytics);
}
// app/layout.tsx - register once at the root, client-side only
'use client';
import { useEffect } from 'react';
import { reportWebVitals } from '@/lib/vitals';

export function VitalsReporter() {
  useEffect(() => {
    reportWebVitals();
  }, []);
  return null;
}

With telemetry flowing, the next step is fixing the LCP element directly. A common regression pattern is a hero banner fetched from a CMS where the engineer forgot to mark it as the priority resource:

import Image from 'next/image';

export function HeroBanner({ imageUrl, alt }: { imageUrl: string; alt: string }) {
  return (
    <Image
      src={imageUrl}
      alt={alt}
      width={1600}
      height={900}
      priority // preloads and skips lazy-loading for the LCP candidate
      sizes="100vw"
      quality={80}
    />
  );
}

Reserving space for asynchronously loaded content prevents CLS without sacrificing perceived speed:

function ProductCard({ product }: { product: Product | null }) {
  if (!product) {
    // skeleton must match the real content's final dimensions
    return <div className="h-[320px] w-[240px] animate-pulse rounded-lg bg-gray-200" />;
  }

  return (
    <div className="h-[320px] w-[240px] rounded-lg">
      <Image src={product.image} alt={product.name} width={240} height={180} />
      <h3>{product.name}</h3>
      <p>{product.price}</p>
    </div>
  );
}

Finally, streaming with Suspense boundaries lets slow data dependencies avoid blocking the whole page's LCP:

// app/product/[id]/page.tsx
import { Suspense } from 'react';

export default function ProductPage({ params }: { params: { id: string } }) {
  return (
    <div>
      <ProductHeader productId={params.id} /> {/* fast, renders immediately */}
      <Suspense fallback={<ReviewsSkeleton />}>
        <ProductReviews productId={params.id} /> {/* slow, streams in later */}
      </Suspense>
    </div>
  );
}

Trade-offs and Common Pitfalls

Optimizing Core Web Vitals is rarely free, and treating it as an unconditional good leads to its own failure modes. The first trade-off is between rendering strategy and data freshness. Static generation gives you the best possible TTFB and LCP, but it means your content can be stale until the next build or ISR revalidation. Teams chasing an LCP score sometimes over-cache dynamic, personalized content, which then causes support tickets when users see outdated inventory or pricing. Choosing SSG, ISR, or SSR per-route based on actual data volatility, rather than reflexively defaulting to one strategy across the whole app, is the more sustainable approach.

The second pitfall is prioritizing lab scores over field data. It's common for a team to get Lighthouse to a 95+ performance score locally and then find their CrUX report in Search Console still shows a "poor" LCP for real users. This gap usually comes from device and network diversity that Lighthouse's default throttling profile doesn't represent, or from third-party scripts (analytics, ads, chat widgets, A/B testing tools) that are absent in a clean lab run but present in production. Every third-party script is a main-thread tax that directly threatens INP, and it's often owned by marketing or growth teams rather than engineering, which makes it organizationally harder to remove than a code-level fix. Loading such scripts with next/script's strategy="lazyOnload" or worker strategies (the latter via Partytown, which runs scripts in a web worker) can mitigate but not eliminate this cost.

A third, more subtle trade-off is over-memoization. In response to INP concerns, some teams wrap every component in React.memo and every value in useMemo/useCallback reflexively. This adds cognitive overhead, can introduce stale-closure bugs, and in cases where the comparison cost exceeds the render cost, can make things slower rather than faster. Memoization is a targeted tool for components that are (a) expensive to render and (b) re-rendering unnecessarily due to referentially unstable props, not a blanket policy. Profiling with React DevTools' Profiler tab to confirm an actual re-render problem before reaching for memoization saves both performance and maintainability.

Finally, there's a tension between bundle splitting for INP and the added complexity of chunk management. Aggressive code splitting via next/dynamic reduces the JavaScript parsed and executed on initial load, improving TBT-adjacent metrics that correlate with INP, but too many chunks introduce their own overhead: additional network requests, waterfall delays for nested dynamic imports, and harder-to-reason-about loading states. The right granularity is usually route-level and feature-level splitting (route already handled by Next.js's file-based routing, features split manually for heavy components like charting libraries or rich text editors), not splitting every individual component.

Best Practices

A durable Core Web Vitals strategy treats performance as a property of the system, not a one-time cleanup sprint. That starts with CI-level regression detection: run Lighthouse CI or a similar tool against key routes on every pull request, and fail the build (or at minimum flag it prominently) when LCP, INP proxies (Total Blocking Time in lab conditions), or CLS regress beyond a defined budget. Performance budgets, expressed as concrete thresholds per route rather than vague aspirations, give the team an objective gate instead of a subjective judgment call made after the fact.

Second, treat images and fonts as first-class architectural concerns, not implementation details left to whoever built the component last. Standardize on next/image and next/font across the codebase, define a shared set of allowed image sizes and formats, and add lint rules or code review checklists that catch missing width/height/priority attributes before merge, since these are the single most common source of LCP and CLS regressions in practice.

Third, build a habit of auditing third-party scripts on a recurring cadence, not just at initial integration. Marketing and analytics tools accumulate over a product's lifetime, and each one is a candidate for next/script strategy tuning, replacement with a lighter alternative, or removal if usage data shows it's no longer providing value proportional to its performance cost. A quarterly "script audit" tied to actual RUM data (not just page weight) keeps this from becoming invisible technical debt.

Fourth, invest in field monitoring dashboards segmented by device class, connection type, and geography, not just an aggregate score. A single global Web Vitals number can mask a severe regression for users on low-end Android devices or in regions with poor connectivity, precisely the users for whom performance improvements have the largest relative impact on task completion and business outcomes.

Fifth, use React's concurrent rendering features deliberately and incrementally. Wrap expensive, non-urgent state transitions in startTransition, defer derived values with useDeferredValue when appropriate, and lean on Suspense boundaries to let fast content stream ahead of slow content, rather than architecting entire pages around a single blocking data fetch.

Analogies and Mental Models

It helps to think of LCP, INP, and CLS as three separate contracts with the user, each broken in a different way. LCP is the contract of "I showed up when I said I would" - a promise about arrival time. INP is the contract of "I respond when you talk to me" - a promise about conversation, not just presence. CLS is the contract of "I don't move the furniture while you're walking through the room" - a promise about spatial trust. A page can honor one contract while badly violating another: a beautifully fast-loading page (good LCP) that freezes on every click (poor INP) has broken a different promise than a page that loads slowly but responds instantly once ready.

A second useful mental model, specific to Next.js's hybrid rendering, is the restaurant metaphor: SSG is a meal prepared in advance and reheated on demand (fast, but not made-to-order), SSR is cooking to order (fresh, but the customer waits), and streaming with Suspense is being served the appetizer while the entrée is still being prepared. Choosing a rendering strategy per route is choosing which of these service models fits that particular "dish."

80/20 Insight

If you can only invest limited effort, the highest-leverage interventions are, in order: correctly configuring next/image with explicit dimensions and priority on LCP candidates, auditing and lazy-loading or removing render-blocking third-party scripts, reserving layout space for all asynchronously loaded content to eliminate CLS, and instrumenting real user monitoring so you're optimizing against actual field data rather than guesswork. These four changes, none of which require an architectural rewrite, resolve the large majority of Core Web Vitals failures observed in production React and Next.js applications. Concurrent React features and fine-grained rendering-strategy selection matter, but they address the remaining long tail, not the bulk of the problem.

Key Takeaways

Conclusion

Core Web Vitals optimization in a React and Next.js codebase is ultimately a discipline of understanding where time goes: from the moment a request hits your server or CDN edge, through data fetching and rendering, to the moment a browser paints and becomes responsive to a user's touch. None of the three metrics live entirely in the frontend or entirely in the backend; they are emergent properties of the whole system working together. Next.js gives fullstack engineers unusually direct control over this pipeline through its rendering strategies, image and font primitives, and streaming model, but that control is only useful if it's exercised deliberately, informed by field data, and revisited as the application and its third-party dependencies evolve. Treating Web Vitals as a continuous engineering practice, backed by budgets and monitoring, rather than a pre-launch checklist, is what separates teams that maintain good scores from teams that regress six months after their last optimization sprint.

References