Dev Logs
/Next.js/ Chapter 22: Performance Optimization
Chapters
  • 01Chapter 1: Introduction & Setup
  • 02Chapter 2: Project Structure & Configuration
  • 03Chapter 3: Layouts & Pages
  • 04Chapter 4: Linking & Navigation
  • 05Chapter 5: Dynamic Routes & Params
  • 06Chapter 6: Route Groups & Organization
  • 07Chapter 7: Parallel & Intercepting Routes
  • 08Chapter 8: Loading, Suspense & Streaming
  • 09Chapter 9: Error Handling
  • 10Chapter 10: Server & Client Components
  • 11Chapter 11: Data Fetching
  • 12Chapter 12: Server Actions & Mutations
  • 13Chapter 13: Route Handlers
  • 14Chapter 14: Caching & use cache
  • 15Chapter 15: Revalidation & ISR
  • 16Chapter 16: Cache Components & Partial Prerendering
  • 17Chapter 17: Proxy (formerly Middleware)
  • 18Chapter 18: Authentication & Authorization
  • 19Chapter 19: Metadata, SEO & OG Images
  • 20Chapter 20: Images & Fonts
  • 21Chapter 21: Styling
  • 22Chapter 22: Performance Optimization
    • Plain English Explanation
    • Measure first
    • Core Web Vitals
    • The build output
    • Bundle analyzer
    • Real-user metrics
    • Ship less JavaScript
    • Audit your 'use client' boundaries
    • next/dynamic for heavy client components
    • optimizePackageImports
    • serverExternalPackages
    • Choose lighter dependencies
    • Don't wait unnecessarily
    • Kill waterfalls
    • Stream with Suspense
    • Move work off the critical path
    • Cache aggressively
    • Images and fonts
    • React Compiler
    • Navigation and prefetching
    • Build and dev performance
    • Turbopack filesystem caching
    • Memory
    • Concurrent dev and build
    • A diagnostic workflow
    • Common Pitfalls
    • . Optimizing without measuring
    • . Benchmarking in dev mode
    • . priority on many images
    • . useMemo everywhere
    • . Missing sizes on responsive images
    • . Barrel-file imports without optimizePackageImports
    • . Not caching the obvious
    • . Chasing the removed build metrics
    • . Rendering 5,000 rows
    • . Blocking the shell for a slow API
    • When & Why to Use
    • Mini Practice Problems
    • Problem 1: Diagnose
    • Problem 2: Cut the bundle
    • Problem 3: React Compiler — yes or no?
    • Problem 4: Build the plan
    • Interview Notes
    • Common Questions
    • Asked at Companies
    • Visual Memory Aid
    • Key Takeaways
  • 23Chapter 23: Testing
  • 24Chapter 24: Deployment & Self-Hosting
  • 25Chapter 25: Upgrading to Next.js 16
  • 26Chapter 26: Capstone Project
  • 27Chapter 27: React Performance Profiling
All chapters

⚡ Chapter 22: Performance Optimization

Measuring what's actually slow, and the specific levers Next.js 16 gives you to fix it.

📖 Plain English Explanation

Most performance work is wasted because it starts with a guess.

Someone reads that useMemo is good, sprinkles it everywhere, and ships a page that's still slow — because the bottleneck was a 4MB hero image, or a sequential database waterfall, or a 300KB date library pulled into the client bundle by a single 'use client' on the wrong file.

So this chapter is ordered by what actually moves the needle, roughly in the order you should look:

  1. Ship less JavaScript — the boundary problem from Chapter 10
  2. Don't wait unnecessarily — the waterfall problem from Chapter 11
  3. Cache what doesn't change — Chapters 14–16
  4. Optimize images and fonts — Chapter 20
  5. Then micro-optimize rendering

The framework does an enormous amount by default. The remaining work is mostly not undoing it.

📏 Measure first

Core Web Vitals

MetricMeasuresGood
LCP — Largest Contentful PaintWhen the main content appears< 2.5s
INP — Interaction to Next PaintResponsiveness to input< 200ms
CLS — Cumulative Layout ShiftVisual stability< 0.1

LCP is almost always an image or a slow server response. INP is almost always too much JavaScript on the main thread. CLS is almost always a missing image dimension or a late-loading font.

The build output

bash
npm run build

⚠️ Changed in Next.js 16

The Size and First Load JS columns were removed from next build output.

They were inaccurate in a server-driven architecture: Turbopack and webpack disagreed on how to attribute Client Component payloads, and both were wrong in different ways. Rather than show misleading numbers, Next.js stopped showing them.

Measure real performance with Lighthouse, Chrome DevTools, or Vercel Analytics — tools that look at what the browser actually downloads.

You still get the route table showing which routes are static (○), dynamic (ƒ), or partially prerendered:

Route (app)
┌ ○ /                          static
├ ○ /about                     static
├ ● /blog/[slug]               SSG (100 paths)
└ ƒ /dashboard                 dynamic

Every ƒ is worth a question: does this route genuinely need to be dynamic?

Bundle analyzer

bash
npm install -D @next/bundle-analyzer
ts
// next.config.ts
import bundleAnalyzer from '@next/bundle-analyzer'

const withBundleAnalyzer = bundleAnalyzer({
  enabled: process.env.ANALYZE === 'true',
})

export default withBundleAnalyzer({
  // your config
})
bash
ANALYZE=true npm run build

You get an interactive treemap. Look for:

  • A library you didn't expect in the client bundle
  • moment, lodash, or an icon set imported wholesale
  • A big dependency that only one page uses

Real-user metrics

Lab numbers lie. Collect from actual visitors:

tsx
// app/WebVitals.tsx
'use client'
import { useReportWebVitals } from 'next/web-vitals'

export function WebVitals() {
  useReportWebVitals((metric) => {
    navigator.sendBeacon(
      '/api/vitals',
      JSON.stringify({
        name: metric.name,
        value: metric.value,
        rating: metric.rating,
        path: window.location.pathname,
      })
    )
  })
  return null
}
jsx
// app/WebVitals.js
'use client'
import { useReportWebVitals } from 'next/web-vitals'

export function WebVitals() {
  useReportWebVitals((metric) => {
    navigator.sendBeacon(
      '/api/vitals',
      JSON.stringify({ name: metric.name, value: metric.value, rating: metric.rating })
    )
  })
  return null
}

sendBeacon survives page unload, which fetch doesn't.

1️⃣ Ship less JavaScript

The highest-leverage change in almost every Next.js app.

Audit your 'use client' boundaries

bash
grep -rl "use client" app/ components/

For each file, ask: does this need to be a whole client component, or just a piece of it?

tsx
// ❌ 'use client' on a page — everything imported below ships
'use client'
import { Chart } from './Chart'          // 200KB charting lib → bundled
import { formatDate } from '@/lib/date'  // → bundled
import { useState } from 'react'

export default function Page({ data }) {
  const [tab, setTab] = useState('a')
  return <><Tabs value={tab} onChange={setTab} /><Chart data={data} /></>
}
tsx
// ✅ boundary on the leaf
export default async function Page() {
  const data = await getData()
  return (
    <>
      <Tabs />                    {/* only this ships */}
      <Chart data={data} />       {/* server-rendered, 200KB lib stays put */}
    </>
  )
}

This one change routinely cuts bundles by 60–80% on pages that were converted from the Pages Router without rethinking boundaries.

next/dynamic for heavy client components

tsx
// app/editor/page.tsx
import dynamic from 'next/dynamic'

const RichTextEditor = dynamic(() => import('@/components/RichTextEditor'), {
  loading: () => <div className="h-64 animate-pulse rounded bg-slate-100" />,
  ssr: false,      // skip server rendering — it needs the DOM
})

export default function Page() {
  return (
    <div>
      <h1>New post</h1>
      <RichTextEditor />
    </div>
  )
}
jsx
// app/editor/page.js
import dynamic from 'next/dynamic'

const RichTextEditor = dynamic(() => import('@/components/RichTextEditor'), {
  loading: () => <div className="h-64 animate-pulse rounded bg-slate-100" />,
  ssr: false,
})

export default function Page() {
  return (
    <div>
      <h1>New post</h1>
      <RichTextEditor />
    </div>
  )
}

Even better — only load it when the user asks:

tsx
'use client'
import dynamic from 'next/dynamic'
import { useState } from 'react'

const Chart = dynamic(() => import('./Chart'))

export function Analytics() {
  const [show, setShow] = useState(false)
  return (
    <>
      <button onClick={() => setShow(true)}>Show chart</button>
      {show && <Chart />}          {/* 200KB downloads only on click */}
    </>
  )
}

ssr: false only works in Client Components. Server Components already don't ship JavaScript, so there's nothing to defer.

optimizePackageImports

Barrel files (index.ts re-exporting everything) defeat tree shaking. This flag fixes it:

ts
// next.config.ts
const nextConfig: NextConfig = {
  optimizePackageImports: [
    'lucide-react',
    'date-fns',
    '@mui/icons-material',
    'lodash-es',
  ],
}

Without it, import { Search } from 'lucide-react' can pull in every icon. With it, you get the one you asked for. Next.js already applies this to a list of common packages; add yours.

serverExternalPackages

For server-side packages that shouldn't be bundled at all:

ts
// next.config.ts
const nextConfig: NextConfig = {
  serverExternalPackages: ['sharp', 'pdfkit', '@aws-sdk/client-s3'],
}

These get require()d at runtime instead of being processed by the bundler. Faster builds, and it avoids breakage in packages that use dynamic requires or native binaries.

Choose lighter dependencies

HeavyLighter
moment (~70KB)date-fns (tree-shakeable) or Intl.DateTimeFormat (free)
lodashlodash-es with named imports, or plain JS
axiosfetch (built in)
uuidcrypto.randomUUID() (built in)
Full icon packsIndividual imports, or inline SVG

Before adding a dependency, check it on bundlephobia.com.

2️⃣ Don't wait unnecessarily

Kill waterfalls

Chapter 11's core lesson, restated because it's the second-biggest lever:

tsx
// ❌ 900ms
const user = await getUser()
const posts = await getPosts()
const stats = await getStats()

// ✅ 300ms
const [user, posts, stats] = await Promise.all([getUser(), getPosts(), getStats()])

Stream with Suspense

Chapter 8's lesson. Don't make the fast content wait for the slow content:

tsx
export default function Page() {
  return (
    <>
      <Header />                                        {/* instant */}
      <Suspense fallback={<Skeleton />}><Slow /></Suspense>
    </>
  )
}

Move work off the critical path

tsx
import { after } from 'next/server'

export default async function Page() {
  const post = await getPost(slug)

  after(async () => {
    await logPageView(slug)      // runs after the response is sent
    await warmRelatedCache(slug)
  })

  return <Article post={post} />
}

3️⃣ Cache aggressively

Chapters 14–16 in one paragraph: cache anything shared and stable with use cache and a cacheLife that matches how often it changes; tag it and invalidate on write rather than relying on time; enable cacheComponents so the static parts of every page ship from a CDN.

tsx
async function Navigation() {
  'use cache'
  cacheLife('max')
  cacheTag('nav')
  return <Nav items={await getNavItems()} />
}

Rendering your navigation on every request, for every user, when it changes twice a year, is pure waste.

4️⃣ Images and fonts

Chapter 20, compressed:

tsx
<Image
  src="/hero.jpg"
  alt="Hero"
  width={1200}
  height={600}
  priority                                    // LCP element only
  sizes="(max-width: 768px) 100vw, 50vw"      // never skip this
  placeholder="blur"
/>
tsx
const inter = Inter({ subsets: ['latin'], display: 'swap' })

For most content sites, a correctly-configured hero image is a larger LCP improvement than every other item in this chapter combined.

Looking for render profiling? This chapter is about what your app downloads. Measuring what happens after — which components re-render, what a state transition costs, and how to catch a regression in CI — is Chapter 27: React Performance Profiling.

5️⃣ React Compiler

⚠️ New in Next.js 16 (stable)

The React Compiler analyzes your components and inserts memoization automatically. No more hand-written useMemo, useCallback, or React.memo.

bash
npm install -D babel-plugin-react-compiler
ts
// next.config.ts
const nextConfig: NextConfig = {
  reactCompiler: true,
}
tsx
// Before — manual memoization
'use client'
export function List({ items, filter }: Props) {
  const filtered = useMemo(
    () => items.filter((i) => i.name.includes(filter)),
    [items, filter]
  )
  const handleClick = useCallback((id: string) => select(id), [])
  return <>{filtered.map((i) => <Row key={i.id} item={i} onClick={handleClick} />)}</>
}

// After — the compiler handles it
'use client'
export function List({ items, filter }: Props) {
  const filtered = items.filter((i) => i.name.includes(filter))
  const handleClick = (id: string) => select(id)
  return <>{filtered.map((i) => <Row key={i.id} item={i} onClick={handleClick} />)}</>
}

It's stable but off by default, because it relies on Babel and increases build times noticeably. Turn it on if you have a client-heavy app with real re-render problems; skip it if your app is mostly Server Components (which don't re-render at all).

Requirement: your components must follow the Rules of React. Mutating props, reading refs during render, or side effects in the render body will produce warnings — and those are real bugs the compiler is surfacing, not compiler limitations.

6️⃣ Navigation and prefetching

Chapter 4 covered the basics. The tuning knobs:

ts
// next.config.ts
const nextConfig: NextConfig = {
  cacheComponents: true,
  partialPrefetching: true,     // prefetch each route's App Shell
  prefetchInlining: true,       // inline small prefetch payloads into the HTML
}
tsx
// Opt out on large lists
{orders.map((o) => (
  <Link key={o.id} href={`/orders/${o.id}`} prefetch={false}>
    {o.reference}
  </Link>
))}

// Opt in aggressively on high-intent links
<Link href="/checkout" prefetch={true}>Checkout</Link>

And mark genuinely instant routes:

tsx
// app/dashboard/page.tsx
export const instant = true

7️⃣ Build and dev performance

Turbopack filesystem caching

On by default in Next.js 16 — compiler artifacts persist to disk between runs, so the second next dev of the day is dramatically faster than the first.

ts
// next.config.ts
const nextConfig: NextConfig = {
  turbopackFileSystemCache: true,   // default
}

Memory

Next.js 16.3 cut development memory usage by up to 90%. If you're still hitting limits on a large app:

json
{
  "scripts": {
    "build": "NODE_OPTIONS='--max-old-space-size=8192' next build"
  }
}

...and look for the usual culprits: enormous generateStaticParams arrays, huge JSON imported at module scope, or a memory leak in a build plugin.

Concurrent dev and build

Next.js 16 gives next dev and next build separate output directories (.next/dev), so you can run both simultaneously. Useful in CI and when testing a production build without stopping your dev server.

🧭 A diagnostic workflow

When a page is slow, in order:

1. Open DevTools → Network, throttle to Fast 3G, hard reload.
   What's the biggest resource? What arrives last?

2. Run Lighthouse. Note LCP, INP, CLS. Read its specific suggestions.

3. LCP bad?
   ├── Large image?      → next/image, priority, sizes, modern format
   ├── Slow TTFB?        → check for waterfalls; cache; stream
   └── Render-blocking?  → inlineCss, defer non-critical JS

4. INP bad?
   ├── Too much JS?      → audit 'use client'; next/dynamic
   ├── Long tasks?       → React Compiler; virtualize long lists
   └── Heavy hydration?  → push boundaries down

5. CLS bad?
   ├── Images            → width/height or fill + aspect ratio
   ├── Fonts             → next/font
   └── Injected content  → reserve space with min-height

6. Server slow?
   ├── ANALYZE=true npm run build → what's in the bundle?
   ├── Log query timings → find the N+1
   └── NEXT_PRIVATE_DEBUG_CACHE=1 → are you hitting cache?

⚠️ Common Pitfalls

1. Optimizing without measuring

The single most common mistake. Profile first; the bottleneck is rarely where you assume.

2. Benchmarking in dev mode

next dev has no prefetching, no minification, and compiles on demand. It is not slow in the same ways production is. Always test next build && next start.

3. priority on many images

Prioritizing everything prioritizes nothing.

4. useMemo everywhere

Memoization has a cost — an entry in the dependency array to compare, memory to hold. For cheap computations it's a net loss. Either measure, or turn on the React Compiler and delete them all.

5. Missing sizes on responsive images

Silently downloads 4K images for thumbnails. Invisible on your laptop, brutal on mobile data.

6. Barrel-file imports without optimizePackageImports

import { Search } from 'lucide-react' can pull in 1,000 icons.

7. Not caching the obvious

Navigation, footers, category lists, feature flags. All shared, all stable, all frequently re-rendered per request for no reason.

8. Chasing the removed build metrics

Size and First Load JS are gone from Next.js 16 output because they were wrong. Use Lighthouse and the Network tab.

9. Rendering 5,000 rows

No amount of memoization fixes a DOM with 5,000 nodes. Paginate, or virtualize with @tanstack/react-virtual.

10. Blocking the shell for a slow API

One uncached await at the top of a page delays everything below it. Cache it, stream it, or move it deeper (Chapter 16).

🎯 When & Why to Use

Always:
  ✅ next/image with correct sizes
  ✅ next/font
  ✅ Promise.all for independent fetches
  ✅ Suspense around slow sections
  ✅ 'use client' on leaves, not branches

Usually:
  ✅ cacheComponents + use cache for shared data
  ✅ optimizePackageImports for barrel-file libraries
  ✅ prefetch={false} on very large link lists

Sometimes:
  ⚠️ React Compiler — client-heavy apps with real re-render cost
  ⚠️ next/dynamic — genuinely heavy, genuinely optional components
  ⚠️ inlineCss — small stylesheets on marketing pages

Rarely:
  ❌ Manual useMemo/useCallback — measure or use the compiler
  ❌ Custom webpack config — you'll fight Turbopack
  ❌ Micro-optimizing render when the bottleneck is a 4MB image

🏋️ Mini Practice Problems

Problem 1: Diagnose

A product page: LCP 4.8s, INP 340ms, CLS 0.28. The page has a 3MB hero JPEG with no dimensions, a 'use client' at the top importing a charting library, and four sequential awaits.

Rank the fixes by impact and say which metric each one moves.

Problem 2: Cut the bundle

tsx
'use client'
import { format } from 'date-fns'
import { Chart } from 'react-chartjs-2'
import { Search, Menu, X } from 'lucide-react'
import { useState } from 'react'

export default function Dashboard({ data }) {
  const [open, setOpen] = useState(false)
  return (
    <div>
      <button onClick={() => setOpen(!open)}>{open ? <X /> : <Menu />}</button>
      <p>Updated {format(data.updatedAt, 'PPP')}</p>
      <Chart data={data.chart} />
    </div>
  )
}

Rewrite it. What should be a Server Component, what should be dynamically imported, and what config change helps?

Problem 3: React Compiler — yes or no?

  • A. A dashboard with 200 client components and constant re-render complaints
  • B. A marketing site that's 95% Server Components
  • C. A data grid with 10,000 rows and heavy filtering
  • D. A codebase with known Rules of React violations

Problem 4: Build the plan

You inherit a Next.js 16 app: 8s build, 3.2s LCP, 900KB of JavaScript on the homepage. Write your first week's plan in priority order, with how you'd measure each change.

💼 Interview Notes

Common Questions

Q: What's the single highest-impact optimization in a Next.js app? Auditing 'use client' boundaries. One directive too high in the tree pulls every imported dependency into the browser bundle. Pushing it to leaves routinely cuts bundle size by more than half.

Q: Why did Next.js 16 remove Size and First Load JS from the build output? They were inaccurate in a server-driven architecture — Turbopack and webpack attributed Client Component payloads differently and both were wrong. Rather than show misleading numbers, they were removed in favour of measuring with Lighthouse or real-user analytics.

Q: What does the React Compiler do and when would you enable it? It analyzes components and inserts memoization automatically, replacing manual useMemo/useCallback/React.memo. It's stable in Next.js 16 but off by default because it relies on Babel and slows builds. Enable it on client-heavy apps with measurable re-render cost.

Q: How do you find what's making a bundle large? @next/bundle-analyzer with ANALYZE=true npm run build gives an interactive treemap. Look for unexpected libraries in the client bundle, wholesale barrel imports, and large dependencies used by only one route.

Q: What's optimizePackageImports? It rewrites imports from barrel-file packages so only what you use is included. Without it, one named import from an icon library can pull in the whole set.

Q: How do you improve LCP? Identify the LCP element first. If it's an image: next/image with priority, correct sizes, and a modern format. If it's text: reduce TTFB by caching, removing waterfalls, and streaming the shell.

Q: How do you improve INP? Ship less JavaScript. Audit client boundaries, dynamically import heavy components, virtualize long lists, and consider the React Compiler for excessive re-renders.

Q: How do you improve CLS? Always give images explicit dimensions or an aspect ratio, use next/font so fallback metrics match, and reserve space for anything that loads late.

🏢 Asked at Companies

  • Vercel: "A page has a 3-second LCP. Walk me through your diagnosis, in order."
  • Amazon: "The homepage ships 900KB of JavaScript. Where do you start?"
  • Netflix: "How do you decide whether to memoize something?"
  • Cloudflare: "Explain the trade-off between streaming a shell and waiting for a complete render."

📊 Visual Memory Aid

              IMPACT vs EFFORT

  HIGH IMPACT
      │  fix 'use client' boundaries
      │  next/image + sizes + priority
      │  kill waterfalls (Promise.all)
      │  cache shared data
      │  ─────────────────────────────
      │  next/dynamic for heavy components
      │  optimizePackageImports
      │  Suspense boundaries
      │  ─────────────────────────────
      │  React Compiler
      │  inlineCss
      │  manual useMemo
  LOW IMPACT
      └──────────────────────────────►
        LOW EFFORT          HIGH EFFORT


              METRIC → CAUSE → FIX

  LCP  ← big image        → next/image, priority, sizes
       ← slow TTFB        → cache, Promise.all, stream
       ← blocking CSS     → inlineCss

  INP  ← too much JS      → audit 'use client', next/dynamic
       ← long tasks       → React Compiler, virtualize
       ← heavy hydration  → push boundaries down

  CLS  ← no image dims    → width/height or fill
       ← font swap        → next/font
       ← late content     → reserve space


              THE BOUNDARY EFFECT

  'use client' on page.tsx
    └── imports Chart (200KB)   → BUNDLED
    └── imports date-fns        → BUNDLED
    └── imports icons           → BUNDLED
        total: ~400KB

  'use client' on Tabs.tsx only
        total: ~4KB

🎯 Key Takeaways

  1. Measure before you optimize. Lighthouse, the Network tab, and the bundle analyzer will tell you the bottleneck; intuition usually won't.
  2. The 'use client' boundary is the biggest lever. One directive too high drags every transitive import into the browser.
  3. Size and First Load JS were removed from next build in Next.js 16 because they were inaccurate. Use real measurement tools.
  4. The React Compiler replaces manual memoization and is stable in Next.js 16, but off by default and worth enabling only for client-heavy apps.
  5. Waterfalls, uncached shared data, and unsized images account for most real-world slowness — and all three have one-line fixes you've already learned.

Next Chapter: Testing →

Practice: Take a slow page, record Lighthouse and a throttled Network trace as your baseline. Apply one fix at a time — boundaries, then images, then caching — re-measuring after each. Write down which change moved which metric and by how much.


PreviousChapter 21: StylingNextChapter 23: Testing

Open source, free forever. Built by iammhador.

Contribute on GitHub