Field note / nextjs

publishedupdated 2026-05-01#nextjs#performance#core-web-vitals#lcp#cls

Next.js Performance & Core Web Vitals

LCP, CLS, INP optimization — image optimization, font loading, bundle analysis, code splitting, React Compiler, and measuring with Lighthouse.

Overview

Core Web Vitals are Google's user-centric performance metrics that directly affect search ranking. LCP (loading), CLS (visual stability), and INP (responsiveness) each require different optimization techniques. In Next.js, most wins come from using the framework's built-in optimizations correctly: next/image, next/font, Server Components (zero client JS), and correct Suspense boundaries for streaming.


Architecture

CORE WEB VITALS
════════════════════════════════════════════════════════

  LCP (Largest Contentful Paint) — loading performance
  ─────────────────────────────────────────────────────
  Goal: < 2.5s
  Measures: time to render largest image or text block
  Causes of failure:
    • Hero image not preloaded (no priority attribute)
    • Render-blocking JS/CSS in <head>
    • Server slow to respond (TTFB > 600ms)
  Fixes:
    <Image priority /> for above-fold images
    preconnect to origin servers
    CDN + edge caching for TTFB
    Server Components to eliminate client-side data fetching

  CLS (Cumulative Layout Shift) — visual stability
  ─────────────────────────────────────────────────────
  Goal: < 0.1
  Measures: unexpected content movement (images loading and pushing text down)
  Causes of failure:
    • Images without width/height (no reserved space)
    • Fonts loading and swapping (FOUT)
    • Content injected above existing content
  Fixes:
    <Image width height /> always (or fill with sized container)
    next/font with display: 'swap' (or 'optional' for strictest)
    Skeleton screens with exact dimensions

  INP (Interaction to Next Paint) — responsiveness
  ─────────────────────────────────────────────────────
  Goal: < 200ms
  Measures: latency from user interaction to next visual update
  Causes of failure:
    • Long JS tasks blocking main thread
    • Large Client Component bundles
    • React re-renders triggered by unrelated state
  Fixes:
    Server Components (zero client JS = nothing to block)
    useTransition for non-urgent updates
    React.memo / useMemo for expensive renders
    Bundle splitting: dynamic import for heavy components

BUNDLE SIZE IMPACT
════════════════════════════════════════════════════════
  Server Component page:    0 KB client JS (data stays on server)
  Client Component page:    all imports go to bundle

  Common bundle bloat sources:
  moment.js     → 67KB min+gz  → replace with date-fns (tree-shakeable)
  lodash CJS    → 70KB         → use lodash-es or native JS
  chart.js      → 200KB        → dynamic import, load on use
  @mui/material → 100KB+       → import from specific path, not barrel

Technical Implementation

Image Optimization

import Image from 'next/image';

// Hero image: priority loads eagerly, preloads in <head>
export function Hero({ src }: { src: string }) {
  return (
    <div className="relative h-[60vh]">
      <Image
        src={src}
        alt="Hero"
        fill                     // fills container (needs relative parent with dimensions)
        priority                 // LCP element: eager load + <link rel="preload">
        sizes="100vw"            // tells browser this is full-viewport-width
        quality={85}             // balance quality vs size (default 75)
        className="object-cover"
      />
    </div>
  );
}

// Product grid: lazy load below-fold images
export function ProductGrid({ products }: { products: Product[] }) {
  return (
    <div className="grid grid-cols-4 gap-4">
      {products.map(p => (
        <div key={p.id} className="aspect-square relative">
          <Image
            src={p.imageUrl}
            alt={p.name}
            fill
            sizes="(max-width: 768px) 50vw, 25vw"  // responsive sizing hint
            // no priority: lazy loaded by default (below fold)
          />
        </div>
      ))}
    </div>
  );
}
// next/image: auto WebP/AVIF conversion, responsive srcset, lazy loading, CLS prevention

Font Loading Without Layout Shift

// app/layout.tsx — next/font: zero FOUT (font swap) and CLS
import { Inter, Lora } from 'next/font/google';
import localFont from 'next/font/local';

const inter = Inter({
  subsets: ['latin'],
  display: 'swap',         // FOUT allowed (text visible immediately with fallback)
  variable: '--font-inter', // expose as CSS variable for Tailwind
});

const lora = Lora({
  subsets: ['latin'],
  display: 'optional',     // NO FOUT: if font not loaded in ~100ms, skip swap
  weight: ['400', '600'],  // only load needed weights
  variable: '--font-lora',
});

// next/font downloads at build time, self-hosts on your domain
// → eliminates third-party font request (privacy + performance)
// → generates size-adjust CSS so fallback font has same metrics (near-zero CLS)

export default function Layout({ children }: { children: React.ReactNode }) {
  return (
    <html className={`${inter.variable} ${lora.variable}`}>
      <body>{children}</body>
    </html>
  );
}

Code Splitting and Bundle Analysis

// Dynamic import: heavy components loaded only when needed
import dynamic from 'next/dynamic';
import { Suspense } from 'react';

// Chart library: 200KB loaded only when chart tab is opened
const RevenueChart = dynamic(
  () => import('@/components/RevenueChart'),
  {
    loading: () => <div className="h-64 bg-muted animate-pulse rounded" />,
    ssr: false,  // chart.js needs browser APIs — skip SSR
  }
);

// Monaco editor: 1MB loaded only for code editor view
const CodeEditor = dynamic(() => import('@/components/CodeEditor'), { ssr: false });

// useTransition: mark state update as non-urgent, keep UI responsive
import { useTransition } from 'react';

function SearchBar() {
  const [query, setQuery] = React.useState('');
  const [results, setResults] = React.useState([]);
  const [isPending, startTransition] = useTransition();

  function handleSearch(value: string) {
    setQuery(value);  // urgent: update input immediately
    startTransition(() => {
      // non-urgent: search can be interrupted by typing
      setResults(expensiveSearch(value));
    });
  }

  return (
    <>
      <input value={query} onChange={e => handleSearch(e.target.value)} />
      {isPending ? <Spinner /> : <ResultsList results={results} />}
    </>
  );
}
# Bundle analysis: see what's in your JS bundles
ANALYZE=true pnpm build
# Opens bundle analyzer at http://localhost:3000 showing treemap of all chunks
# Look for: large unexpected libraries, duplicate dependencies, unshaken CJS modules

# Add to next.config.ts:
# import withBundleAnalyzer from '@next/bundle-analyzer'
# export default withBundleAnalyzer({ enabled: process.env.ANALYZE === 'true' })(nextConfig)

Interview Preparation

Q: How does next/image prevent Cumulative Layout Shift?

CLS from images happens when the browser renders text first (unknown image dimensions) and then shifts everything down when the image loads. next/image requires either explicit width and height props or the fill prop with a sized container. This lets Next.js generate an inline aspect-ratio CSS style (or padding-bottom trick) that reserves the exact space before the image loads — the browser pre-allocates the layout space. Additionally, next/image auto-generates a srcset with multiple sizes, adds loading="lazy" by default for below-fold images, and converts images to WebP/AVIF — reducing transfer time and therefore the window where layout shift can occur.

Q: What is LCP and what are the most impactful ways to improve it?

LCP (Largest Contentful Paint) measures when the largest content element (usually a hero image or above-fold heading) becomes visible. For a landing page, the hero image is almost always the LCP element. Most impactful fixes: (1) Add priority to the above-fold <Image> — this adds <link rel="preload"> in the <head>, starting download before the browser parses the component tree. (2) Reduce TTFB by moving data fetching to Server Components (eliminating a client round-trip waterfall). (3) Use a CDN to serve the HTML from an edge close to the user. (4) Ensure the image URL is deterministic at render time — if the hero image URL requires a JavaScript request to determine, it will always be late.

Q: What is INP and how do Server Components help?

INP (Interaction to Next Paint) measures how long from a user's tap/click/keypress to the next visual update. It replaced FID in 2024 as a Core Web Vital. The main causes of high INP: long-running JavaScript tasks on the main thread that block the browser from rendering. Server Components help by reducing the amount of JavaScript shipped to the browser — components that have no interactivity ship zero JS, leaving the main thread free for event handling. For components that must be client-side, useTransition marks expensive state updates as non-urgent so React can yield to user interactions mid-render. Code splitting (dynamic imports) reduces initial JS parse time.


Learning Resources