⚡ 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:
- Ship less JavaScript — the boundary problem from Chapter 10
- Don't wait unnecessarily — the waterfall problem from Chapter 11
- Cache what doesn't change — Chapters 14–16
- Optimize images and fonts — Chapter 20
- 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
| Metric | Measures | Good |
|---|---|---|
| LCP — Largest Contentful Paint | When the main content appears | < 2.5s |
| INP — Interaction to Next Paint | Responsiveness to input | < 200ms |
| CLS — Cumulative Layout Shift | Visual 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
npm run build
⚠️ Changed in Next.js 16
The
SizeandFirst Load JScolumns were removed fromnext buildoutput.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
npm install -D @next/bundle-analyzer
// next.config.ts
import bundleAnalyzer from '@next/bundle-analyzer'
const withBundleAnalyzer = bundleAnalyzer({
enabled: process.env.ANALYZE === 'true',
})
export default withBundleAnalyzer({
// your config
})
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:
// 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
}
// 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
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?
// ❌ '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} /></>
}
// ✅ 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
// 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>
)
}
// 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:
'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:
// 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:
// 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
| Heavy | Lighter |
|---|---|
moment (~70KB) | date-fns (tree-shakeable) or Intl.DateTimeFormat (free) |
lodash | lodash-es with named imports, or plain JS |
axios | fetch (built in) |
uuid | crypto.randomUUID() (built in) |
| Full icon packs | Individual 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:
// ❌ 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:
export default function Page() {
return (
<>
<Header /> {/* instant */}
<Suspense fallback={<Skeleton />}><Slow /></Suspense>
</>
)
}
Move work off the critical path
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.
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:
<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"
/>
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.
npm install -D babel-plugin-react-compiler
// next.config.ts
const nextConfig: NextConfig = {
reactCompiler: true,
}
// 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:
// 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
}
// 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:
// 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.
// 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:
{
"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
'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
- Measure before you optimize. Lighthouse, the Network tab, and the bundle analyzer will tell you the bottleneck; intuition usually won't.
- The
'use client'boundary is the biggest lever. One directive too high drags every transitive import into the browser. SizeandFirst Load JSwere removed fromnext buildin Next.js 16 because they were inaccurate. Use real measurement tools.- 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.
- 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.