Contents
31 Chapter

Performance · Bundles · Web Vitals

Measuring and improving Core Web Vitals (LCP · INP · CLS). Lighthouse · bundle analysis · code splitting · RSC streaming · next/image · next/font · INP optimization.

Chapter 30 layered on a functional safety net with automated tests. But “it works” and “it works fast” are different problems. This chapter looks at the tools for measuring and improving the performance users actually feel.

If Chapter 14 (Performance Optimization) dealt with React-internal re-render cost, this chapter looks at the cost one layer further out. Bundle download, first paint, interaction response — the dimensions users feel directly. We also take another look at how the streaming built in Chapter 26 (Suspense and use()) plays in this dimension. The real production RUM data of this chapter meets Sentry / PostHog in the next chapter (Chapter 33, Deploy and Observability).

Do not optimize without measuring #

The first rule of performance work. Optimizing on impression or guesswork usually touches the wrong place and only adds cost.

wrong flow
"feels slow" → add more useMemo → actually gets slower
right flow
measure → identify the biggest cost → improve one thing → measure again → repeat

Every tool in this chapter belongs in the “measure” step above. Knowing the tools matters, but the more important stance is to measure first, always.

Core Web Vitals — three metrics #

Three metrics Google has set as the standard for search ranking and user experience.

LCP (Largest Contentful Paint) #

The time until the largest content on the page (an image, a large text block) is visible.

  • Good: within 2.5 seconds
  • Needs improvement: 2.5 ~ 4 seconds
  • Poor: 4 seconds or longer

This metric was the biggest weakness of CSR we saw in Chapter 22. Moving to RSC + SSR improves LCP significantly.

INP (Interaction to Next Paint) #

The time between a user click / tap / key press and the next paint. A new metric that replaced FID (First Input Delay) in 2024. It is based on the worst interaction delay over the page’s lifetime.

  • Good: within 200ms
  • Needs improvement: 200 ~ 500ms
  • Poor: 500ms or longer

JS long tasks are the main culprit for ruining INP. This connects directly to useMemo / React Compiler in Chapter 14.

CLS (Cumulative Layout Shift) #

The cumulative amount of layout movement during the page’s lifetime. It catches the phenomenon where content jumps as images / fonts load late.

  • Good: 0.1 or lower
  • Needs improvement: 0.1 ~ 0.25
  • Poor: 0.25 or higher

Not giving images width/height, or letting fonts swap from a fallback so text resizes, makes CLS worse.

Measurement tools #

Lighthouse — lab data #

The tool built into Chrome DevTools. It gives one measurement in a lab environment (simulated network and CPU).

running Lighthouse
DevTools → Lighthouse tab → Analyze page load

Strengths: immediate result, improvement suggestions shown alongside.

Limit: the lab environment cannot fully reflect real users’ varied conditions (low-end devices, slow networks).

PageSpeed Insights (PSI) #

A web tool Google hosts — pagespeed.web.dev. It shows Lighthouse plus real user data (CrUX) side by side.

CrUX (Chrome User Experience Report) is a 28-day average of what Chrome users actually saw. It reflects the distribution of real user conditions.

web-vitals library — production RUM #

Lab data is a single snapshot and cannot see the spread real users experience. Real User Monitoring (RUM) fills that gap.

src/app/web-vitals.ts
'use client';

import { onCLS, onINP, onLCP } from 'web-vitals';

type Metric = {
  name: string;
  value: number;
  rating: 'good' | 'needs-improvement' | 'poor';
};

function report(metric: Metric) {
  // Send to PostHog / Sentry (covered in Chapter 33)
  navigator.sendBeacon('/api/vitals', JSON.stringify(metric));
}

export function reportVitals() {
  onCLS(report);
  onINP(report);
  onLCP(report);
}

Next.js has its own useReportWebVitals hook.

src/app/layout.tsx — Next.js useReportWebVitals
'use client';

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

export function WebVitals() {
  useReportWebVitals(metric => {
    navigator.sendBeacon('/api/vitals', JSON.stringify(metric));
  });
  return null;
}
src/app/layout.tsx — wired into the root layout
import { WebVitals } from './web-vitals';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="ko">
      <body>
        <WebVitals />
        {children}
      </body>
    </html>
  );
}

navigator.sendBeacon delivers reliably even when the page is closing. A plain fetch can be cancelled mid-navigation.

Bundle analysis #

The smaller the JavaScript sent to the client, the sooner the page comes alive. The tool to see what is in the bundle is @next/bundle-analyzer.

install
pnpm add -D @next/bundle-analyzer
next.config.ts
import withBundleAnalyzer from '@next/bundle-analyzer';

const analyze = withBundleAnalyzer({ enabled: process.env.ANALYZE === 'true' });

export default analyze({
  // ... other Next.js config
});
run the analysis
ANALYZE=true pnpm build

When the build finishes, a treemap opens in the browser. Big boxes are heavy packages. Heavy candidates we often spot:

  • Full imports of moment or date-fns (a pattern that does not tree-shake)
  • Chart libraries (recharts, chart.js)
  • A markdown / MDX parser bundled into the client
  • Full imports of lodash

The fixes:

  • momentdate-fns or native Intl.DateTimeFormat
  • Chart libraries → lazy-import via next/dynamic only on the page that uses charts
  • Markdown parser → import only inside Server Components (it does not slip into the client automatically)
  • lodash → named imports only from lodash-es

Code splitting and lazy import #

The most direct way to shrink bundle size is to download only when needed.

next/dynamic — component-level lazy #

lazy-import a heavy component
import dynamic from 'next/dynamic';

const HeavyChart = dynamic(() => import('./HeavyChart'), {
  loading: () => <p>Loading chart...</p>,
  ssr: false,  // render only in the browser (skip SSR)
});

export default function DashboardPage() {
  return (
    <main>
      <h1>Dashboard</h1>
      <HeavyChart />
    </main>
  );
}

ssr: false is the option that skips SSR. Use it when a browser-only library (for example, parts of chart.js) errors out during SSR. Skipping SSR can delay LCP, though, so use it only when truly needed.

React.lazy + Suspense — the general pattern #

using React.lazy
import { lazy, Suspense } from 'react';

const HeavyChart = lazy(() => import('./HeavyChart'));

export default function DashboardPage() {
  return (
    <Suspense fallback={<p>Loading chart...</p>}>
      <HeavyChart />
    </Suspense>
  );
}

In Next.js, next/dynamic is more common, but in a plain Vite setup, React.lazy is the choice.

Route-level vs component-level #

  • Route-level: code is split per route. In Next.js’s App Router this is automatic. Code on a different page is only downloaded when you visit that page.
  • Component-level: a heavy component on the same page is lazy. The next/dynamic pattern above.

Route splitting is automatic, so it rarely requires conscious attention. Component-level lazy pays off when “the user experience is fine even if this component appears later.”

How RSC streaming affects LCP #

The Suspense + streaming from Chapter 26 directly affects perceived LCP. Let us revisit briefly the part Chapter 26 only touched on.

A page without streaming #

🐢 awaiting all data at the top of the page function
export default async function Page() {
  const profile = await getProfile();   // 100ms
  const posts = await getPosts();       // 2000ms
  const stats = await getStats();       // 3000ms
  return (
    <div>
      <Profile data={profile} />
      <Posts data={posts} />
      <Stats data={stats} />
    </div>
  );
}

The LCP of this page is after 3000ms. HTML only goes to the client once the slowest data has been awaited.

With streaming #

🚀 streaming with Suspense
export default async function Page() {
  const profile = await getProfile();  // 100ms

  return (
    <div>
      <Profile data={profile} />
      <Suspense fallback={<Skeleton />}><PostsSection /></Suspense>
      <Suspense fallback={<Skeleton />}><StatsSection /></Suspense>
    </div>
  );
}

This page sends the first HTML chunk to the client at 100ms. If the LCP candidate sits inside Profile, LCP lands near 100ms. With the same data volume, perceived speed is 30x faster.

Suspense boundary choice directly affects LCP #

Be aware of where the LCP candidate sits and place the Suspense boundary accordingly.

  • Do not place the LCP candidate inside a Suspense. Hiding it behind a fallback defeats the purpose.
  • Isolate the slow children inside Suspense so the LCP candidate becomes visible quickly.

This is the performance reading of the “where to place the boundary” guideline from Chapter 26.

Images — next/image and LCP #

The LCP candidate is often an image. next/image automatically handles the features that directly affect LCP.

next/image basics
import Image from 'next/image';

export default function Hero() {
  return (
    <Image
      src="/hero.jpg"
      width={1200}
      height={600}
      alt="Hero image"
      priority  // attach priority if it is the LCP candidate
    />
  );
}

The essentials.

  • priority: attach it to LCP-candidate images. A preload hint is added automatically so the image downloads fastest.
  • width / height: aspect ratio information rather than pixel size. Protects CLS by preventing layout shifts.
  • Automatic format conversion (WebP / AVIF), responsive srcset generation.

If LCP is not getting picked up, the first place to suspect is whether priority is missing on the hero image.

Fonts — next/font and CLS #

Before a web font loads, a fallback font is used; when it arrives, a swap shifts text size slightly. That jump damages CLS.

next/font handles this automatically.

src/app/layout.tsx — using next/font
import { Pretendard } from 'next/font/google';

const pretendard = Pretendard({
  subsets: ['latin'],
  display: 'swap',
  preload: true,
});

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="ko" className={pretendard.className}>
      <body>{children}</body>
    </html>
  );
}

What it handles automatically:

  • Hosts the font at build time (removes external CDN dependence like Google Fonts)
  • Analyzes font metadata to match metrics with the fallback (size-adjust)
  • Auto-preload

Loading external fonts directly via <link> without next/font tends to drop CLS. next/font is the standard for new projects.

Improving INP #

INP is the response time to user interaction. The most common cause is a long synchronous task blocking the main thread.

Identifying long tasks #

Recording in the DevTools Performance tab marks long tasks (50ms or longer) in red. Analyze what that task is.

Common long tasks:

  • Heavy list rendering (1000+ items without virtualization)
  • Large JSON parsing (multi-MB responses)
  • Heavy synchronous computation (sort, filter, statistics)

React 19’s React Compiler #

The React Compiler covered in Chapters 14 and 28 reduces unnecessary re-renders through automatic memoization, which indirectly helps INP. The fundamental fix for INP, though, is to shrink the computation itself or move it to another thread.

useTransition — lower-priority updates #

lower-priority updates with transition
'use client';

import { useState, useTransition } from 'react';

export default function SearchBox() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState<string[]>([]);
  const [isPending, startTransition] = useTransition();

  function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
    setQuery(e.target.value);  // immediate update (high priority)
    startTransition(() => {
      setResults(computeExpensiveResults(e.target.value));  // can be deferred
    });
  }

  return (
    <>
      <input value={query} onChange={handleChange} />
      {isPending && <p>Searching...</p>}
      <ul>{results.map(r => <li key={r}>{r}</li>)}</ul>
    </>
  );
}

Updates inside startTransition are processed with lower priority by React. User input reflects immediately, and the heavy result update is processed when the main thread has room.

scheduler.yield() (experimental) #

A new API for yielding between interactions during a long synchronous computation.

using scheduler.yield (experimental)
async function heavyWork(items: Item[]) {
  for (const item of items) {
    processItem(item);
    if ('scheduler' in window && 'yield' in scheduler) {
      await scheduler.yield();  // yield if there is interaction
    }
  }
}

Browser support is not yet uniform, so a polyfill or fallback is needed. The generally safer option is moving to a Web Worker, but for simple cases scheduler.yield() is a lightweight remedy.

Relationship with Chapter 14 (Performance Optimization) #

Let us spell out which metric in this chapter is affected by Chapter 14’s useMemo / useCallback / memo and the React Compiler.

Chapter 14 toolMetric affectedWhere applied
memoINPa child component that re-renders often
useMemoINPcaching the result of an expensive computation
useCallbackINPmeaningful only when keeping a child’s memo alive
React CompilerINPautomates the three above

All Chapter 14 tools act on render cost and INP. LCP / CLS are not touched by Chapter 14 but by this chapter’s tools (images, fonts, streaming). Keeping the three roles distinct in mind keeps optimization direction from drifting.

Try it yourself — Web Vitals improvement on a small page #

Pick one of this book’s example sites and run through this cycle once.

  1. Measure: run Lighthouse on a page launched from a production build (pnpm build && pnpm start). Look at the LCP / INP / CLS scores along with the “Opportunities” section.
  2. Bundle analysis: open the treemap with ANALYZE=true pnpm build and identify the three biggest boxes.
  3. Improve — LCP: add priority to the hero image and swap to next/image. Measure again and observe the difference.
  4. Improve — CLS: move the font to next/font and specify width/height on all images. Measure again.
  5. Improve — INP: wrap interactions like search or filter in useTransition, and apply virtualization or pagination to large lists. Measure again.

Recording the score changes at each step makes it clear “which tool affects which metric by how much.”

Exercises #

  1. Sort the metrics. For the following five symptoms, answer which of LCP / INP / CLS would be damaged. (a) A large hero image is lazy-loaded. (b) The screen reaction to a click takes 1 second. (c) Text size jumps slightly during page load. (d) The JS bundle is 3MB. (e) An ad slot without a width pushes content down.
  2. Assessing the value of streaming. Among the CSR / SSR / RSC models from Chapter 22, describe in one paragraph the shape of page where streaming makes the biggest difference. Hint: contrasting the streaming value on “a page with heavy data dependence” vs “a page with almost no data” lands you close to the answer.
  3. Bundle diet plan. Use @next/bundle-analyzer to analyze your project (or one of this book’s examples), pick the two biggest packages, and decide which of the following strategies to use to shrink each — (a) replace with a lighter alternative, (b) lazy via next/dynamic, (c) move to Server Component only to remove from the client. Write one line of trade-offs per strategy.

In one line: performance improvement is the cycle “measure → identify → improve one thing → measure again.” Measure the three Core Web Vitals (LCP · INP · CLS) with Lighthouse (lab) and web-vitals (RUM). LCP is handled by next/image priority and RSC streaming, CLS by next/font and image sizing, INP by Chapter 14’s memoization and useTransition. Use bundle analysis to identify the biggest weight and remove it from the client via next/dynamic or by moving to a Server Component. Production RUM data flows on to PostHog / Sentry in the next chapter (Chapter 33, Deploy and Observability).

Next chapter #

The next chapter, Chapter 32 Auth and Sessions — Auth.js v5 / OAuth / JWT, looks at the first entry barrier of a fullstack app: auth and sessions. We sort out how to read the session in four places — RSC / Client Component / Server Action / middleware — and tie together the choice criteria for JWT vs DB session and the standard setup for Auth.js v5.

X