Next.js Performance: Measure First, Then Images, Bundle Splitting, Caching and Core Web Vitals
Key takeaways
Slow Next.js apps lose users and rank lower in search. This guide covers the most impactful performance optimizations — from bundle size reduction to image optimization and caching strategies — with real measurement techniques.
Why Next.js Performance Matters
A 100ms increase in load time reduces conversion by 1%. Core Web Vitals directly affect Google rankings. And with Next.js’s rich feature set, it’s easy to accidentally ship a slow app.
This guide focuses on measurement first, optimization second — never guess.
I’ve watched a team spend a sprint dynamic-importing every component they could find, convinced their app “felt slow,” only to discover afterward — via an actual bundle analysis and Lighthouse run — that the real problem was a single 4MB hero image served without next/image, and the LCP score barely moved after all that refactoring work. The lesson that stuck: performance work without measurement first is just guessing with extra steps, and it’s very easy to spend real engineering time optimizing something that was never the bottleneck while the actual bottleneck sits untouched.
Measure First
# Build and analyze
npm run build
# Check what's included in your bundle
ANALYZE=true npm run build
# (requires @next/bundle-analyzer)
# Real-user metrics
# Next.js Speed Insights in vercel.com dashboard
# Or: Google Search Console → Core Web Vitals
Install bundle analyzer
npm install @next/bundle-analyzer
// next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true',
})
module.exports = withBundleAnalyzer({
// your config
})
ANALYZE=true npm run build
# Opens treemap of bundle contents in browser
The distinction worth internalizing between these two measurement approaches: ANALYZE=true npm run build tells you what’s in your bundle — genuinely useful for spotting an accidentally-included heavy dependency — but it says nothing about what real users actually experience on real devices and real networks. Vercel Speed Insights and Search Console’s Core Web Vitals report are field data, collected from actual visitors, which is why they’re the ones that matter for SEO ranking and for catching problems a fast dev machine on fast wifi would never surface (a heavy JS bundle that’s fine on a laptop but genuinely janky on a mid-range Android phone on 4G). Bundle analysis tells you where to look; field data tells you whether it mattered.
Image Optimization
Images are the #1 LCP killer. Always use next/image:
import Image from 'next/image'
// ❌ Bad: regular img tag
<img src="/hero.jpg" alt="Hero" style={{ width: '100%' }} />
// ✅ Good: next/image with correct props
<Image
src="/hero.jpg"
alt="Hero image"
width={1200}
height={600}
priority // LCP image: load immediately, don't lazy-load
quality={85} // 75-85 is the sweet spot
placeholder="blur" // show blurred placeholder while loading
blurDataURL="data:image/jpeg;base64,..." // tiny base64 preview
/>
// Below-the-fold images: lazy load (default)
<Image
src="/product.jpg"
alt="Product"
width={400}
height={400}
// priority NOT set → lazy loaded automatically
/>
priority is the setting most likely to be missing or, just as commonly, applied wrong — I’ve seen both directions cause real regressions. Forgetting it on the actual LCP image means that image gets lazy-loaded by default, which delays exactly the element LCP is measuring and directly hurts the score. But sprinkling priority on several images “just in case” is just as bad: it tells the browser to fetch all of them immediately at full priority, competing for the same limited early-connection bandwidth, which can actually delay the one image that genuinely determines LCP. The rule that actually works in practice: identify the single largest above-the-fold element (usually via Lighthouse’s LCP element callout) and set priority on that one image only — treating it as a scarce resource, not a checkbox to tick everywhere.
Remote images
// next.config.js
module.exports = {
images: {
remotePatterns: [
{ protocol: 'https', hostname: 'images.unsplash.com' },
{ protocol: 'https', hostname: 'cdn.yoursite.com' },
],
formats: ['image/avif', 'image/webp'], // serve AVIF first, WebP fallback
},
}
remotePatterns isn’t a formality Next.js makes you fill out — next/image refuses to optimize an external image whose domain isn’t explicitly allow-listed here, failing at build or request time with a fairly clear error, which is a deliberate security boundary against arbitrary remote-image processing rather than an arbitrary restriction. It’s worth remembering the first time a CMS or a new image CDN gets wired up and images mysteriously don’t render — the fix is almost always a missing entry here, not a bug in the component using next/image.
Bundle Size Reduction
Dynamic imports (code splitting)
import dynamic from 'next/dynamic'
// Heavy component loaded only when needed
const HeavyChart = dynamic(() => import('./HeavyChart'), {
loading: () => <div>Loading chart...</div>,
ssr: false, // client-only (e.g., uses window)
})
// Heavy library (e.g., markdown editor, date picker)
const MarkdownEditor = dynamic(() => import('react-md-editor'), { ssr: false })
// Only load when user interacts
const [showModal, setShowModal] = useState(false)
const Modal = dynamic(() => import('./HeavyModal'))
return (
<>
<button onClick={() => setShowModal(true)}>Open Modal</button>
{showModal && <Modal onClose={() => setShowModal(false)} />}
</>
)
The ssr: false option deserves a specific callout, because getting it wrong produces a genuinely confusing error rather than a quiet performance miss: it’s required for any component that touches browser-only globals (window, document, localStorage) at module load or render time, since Next.js’s default behavior tries to server-render dynamically-imported components too, and a server has no window to reference — the resulting error (“window is not defined”) points at the imported component’s code, not at the missing ssr: false, which makes it a non-obvious fix the first time it happens. The modal example at the bottom demonstrates the highest-leverage pattern in this whole section: gating the dynamic import behind actual user interaction (showModal &&) means the modal’s code doesn’t even get requested until a user clicks the button that opens it — for a rarely-opened modal, that’s code a large fraction of visitors never download at all.
Replace heavy dependencies
# Before replacing, check your bundle
ANALYZE=true npm run build
# Common replacements
lodash → lodash-es (tree-shakeable) or individual functions
moment → date-fns or dayjs (~80% smaller)
axios → native fetch (zero bytes)
react-icons → @phosphor-icons/react (tree-shakeable)
// ❌ Imports entire lodash
import _ from 'lodash'
const sorted = _.sortBy(users, 'name')
// ✅ Tree-shakeable import
import { sortBy } from 'lodash-es'
const sorted = sortBy(users, 'name')
The reason import _ from 'lodash' pulls in the entire library while import { sortBy } from 'lodash-es' doesn’t comes down to module format, not the function count imported — plain lodash ships as CommonJS, where module.exports is one dynamic object a bundler can’t statically prove is only partially used, so it conservatively bundles the whole thing. lodash-es ships each function as its own real ES module with a clear, individually-analyzable export, which is what actually lets tree-shaking eliminate the functions you never imported. This is the same underlying mechanism (and the same fix) as the CommonJS-vs-ESM tree-shaking gap covered in more depth in the React performance guide elsewhere on this site — worth knowing once, since it applies to any CommonJS dependency, not just lodash specifically.
Analyze and remove unused code
# Find unused exports
npx knip
# Check package sizes before installing
npx bundlephobia <package-name>
bundlephobia before installing anything new is a habit worth having specifically because a package’s npm description rarely mentions its actual weight — a date-formatting utility that looks like a small convenience import can pull in a genuinely large transitive dependency tree, and finding that out after it’s already woven through a codebase is a much more expensive fix than checking up front. knip complements this from a different angle: it’s not about what’s heavy, it’s about what’s genuinely dead — exports and dependencies nothing in the codebase actually references anymore, which tend to accumulate silently after refactors and rarely get cleaned up without a tool explicitly flagging them.
Caching Strategies (App Router)
// Static (cached forever, revalidated on deploy)
export const dynamic = 'force-static'
// Static with time-based revalidation (ISR)
export const revalidate = 3600 // revalidate every hour
// Dynamic (no cache, runs on every request)
export const dynamic = 'force-dynamic'
// Per-fetch caching
const data = await fetch('/api/posts', {
next: { revalidate: 300 } // cache for 5 minutes
})
// No cache for this specific fetch
const user = await fetch('/api/user', {
cache: 'no-store'
})
// Revalidate on demand (after form submit, admin action)
import { revalidatePath, revalidateTag } from 'next/cache'
async function publishPost(postId: string) {
await db.posts.update({ id: postId, published: true })
revalidatePath('/blog') // revalidate the blog listing
revalidatePath(`/blog/${postId}`) // revalidate this specific post
}
I burned a genuinely frustrating afternoon early in the App Router’s life debugging a page that kept showing stale data after an admin action, and the root cause was exactly this: revalidatePath('/blog') only invalidates the cache for that specific path, not /blog/${postId} too — two separate cache entries that both needed explicit invalidation, and I’d only called the first one. Next.js’s App Router caches aggressively and in multiple overlapping layers (the full-route cache, the data cache, the router cache on the client) by default, which is genuinely the most-cited source of confusion in App Router adoption — when data looks stale after a mutation, the fix is almost always a missing revalidatePath/revalidateTag call for the specific path that’s actually stale, not a broader caching misconfiguration, and it’s worth checking that first before assuming something more complicated is wrong.
Reducing LCP (Largest Contentful Paint)
LCP measures when the largest element is visible. Target: < 2.5s.
// ✅ Preload LCP image
import { headers } from 'next/headers'
// In layout.tsx or page.tsx
export default function Layout({ children }) {
return (
<html>
<head>
<link
rel="preload"
as="image"
href="/hero.jpg"
// For responsive images:
imageSrcSet="/hero-800.jpg 800w, /hero-1200.jpg 1200w"
/>
</head>
<body>{children}</body>
</html>
)
}
// ✅ Priority on LCP image
<Image src="/hero.jpg" priority alt="Hero" width={1200} height={600} />
// ✅ Preconnect to external image domains
<link rel="preconnect" href="https://images.unsplash.com" />
preconnect and <Image priority> are solving different parts of the same timeline, worth being precise about: preconnect gets the DNS lookup, TCP handshake, and TLS negotiation for an external domain done before the browser even discovers it needs to fetch something from there, shaving off round-trip time that would otherwise happen serially once the image request is actually issued. priority on the Image component controls when in the render pipeline the fetch is kicked off relative to other resources. They’re complementary, not redundant — preconnecting to a domain you never actually load a priority image from does nothing useful, and prioritizing an image without preconnecting to its (slow, external) domain still pays the full connection-setup cost on first fetch.
Preventing CLS (Cumulative Layout Shift)
CLS measures visual stability. Target: < 0.1.
// ❌ Image without dimensions causes CLS
<img src="/logo.png" alt="Logo" />
// ✅ Always specify dimensions
<Image src="/logo.png" alt="Logo" width={120} height={40} />
// ❌ Dynamic content loading without placeholder
{user ? <UserAvatar user={user} /> : null}
// ✅ Reserve space while loading
<div style={{ width: 40, height: 40 }}>
{user ? <UserAvatar user={user} /> : <Skeleton variant="circular" />}
</div>
// The subtlety worth catching here: reserving space isn't just "wrap it
// in a div" — the div's dimensions have to match the eventual content's
// actual size exactly. A skeleton that's 40x40 standing in for content
// that renders at a slightly different size (say, because the real
// avatar has different padding) still causes a shift once the swap
// happens, just a smaller one than having no placeholder at all — CLS
// measures the actual layout delta, not whether you tried.
// ❌ Web font without fallback (text shifts when font loads)
// ✅ Use next/font (automatic font optimization)
import { Inter } from 'next/font/google'
const inter = Inter({
subsets: ['latin'],
display: 'swap',
preload: true,
})
next/font solves a problem that’s easy to underestimate the impact of: a <link> to Google Fonts loads the font asynchronously by default, so text initially renders in a fallback system font and then visibly reflows once the web font finishes downloading — a jarring, CLS-triggering “flash of unstyled text” that’s especially noticeable on slower connections. next/font downloads and self-hosts the font files at build time (no runtime request to Google’s servers at all, which also removes a third-party network dependency and its associated privacy/performance cost) and automatically generates fallback font metrics that closely match the real font’s size, which is what actually minimizes the layout shift when the real font swaps in — display: 'swap' alone doesn’t prevent the shift, it just controls whether text is invisible or shown-in-fallback during the wait.
Reducing INP (Interaction to Next Paint)
INP measures responsiveness to clicks and inputs. Target: < 200ms.
// ❌ Heavy synchronous computation blocks the main thread
function handleSearch(query: string) {
const results = heavySearch(allProducts, query) // blocks UI
setResults(results)
}
// ✅ Defer non-critical work
import { startTransition } from 'react'
function handleSearch(query: string) {
setQuery(query) // urgent: update input immediately
startTransition(() => {
setResults(heavySearch(allProducts, query)) // defer: can be interrupted
})
}
// startTransition doesn't make heavySearch itself run faster — it tells
// React this update is low-priority and interruptible, so if the user
// types another character before the search finishes, React can abandon
// the stale in-progress render and start fresh rather than finishing a
// computation whose result is already outdated. The visible effect is
// that the input field stays responsive to every keystroke even while a
// search over a large dataset is still crunching in the background,
// instead of the whole UI freezing on the main thread until it completes.
// ✅ Use useDeferredValue for expensive renders
const deferredQuery = useDeferredValue(query)
const results = useMemo(() => search(deferredQuery), [deferredQuery])
// ✅ Move heavy work to web workers
const worker = new Worker(new URL('./search.worker.ts', import.meta.url))
worker.postMessage({ query })
worker.onmessage = (e) => setResults(e.data)
useDeferredValue and a Web Worker solve genuinely different problems, and reaching for the wrong one is a common mistake. useDeferredValue/startTransition still run the heavy work on the main thread — they just deprioritize when it runs relative to more urgent updates, which helps with perceived responsiveness but doesn’t reduce actual CPU cost or free up the main thread for anything else happening concurrently. A Web Worker genuinely moves the computation to a separate OS thread, which is the right escalation once the work is heavy enough that even a deprioritized main-thread computation would noticeably block other things (animations, other user input) while it runs — worth reaching for startTransition first as the simpler fix, and only introducing worker-thread complexity once profiling shows it’s still not enough.
Server Components vs Client Components
Getting this boundary right is crucial for performance:
// ✅ Server Component (default in App Router)
// - Zero JS sent to client
// - Can directly access DB/API
// - Can't use useState, useEffect, event handlers
async function PostList() {
const posts = await db.posts.findMany() // direct DB access
return <ul>{posts.map(p => <li key={p.id}>{p.title}</li>)}</ul>
}
// ✅ Client Component (add 'use client' only when needed)
// - Has interactivity (useState, useEffect, events)
// - JS is sent to client
'use client'
function SearchInput({ onSearch }) {
const [query, setQuery] = useState('')
return <input value={query} onChange={e => { setQuery(e.target.value); onSearch(e.target.value) }} />
}
// ✅ Optimal: Server Component wraps Client Component
async function SearchPage() {
const initialPosts = await db.posts.findMany() // server data
return (
<div>
<SearchInput onSearch={handleSearch} /> // client interaction
<PostList posts={initialPosts} /> // server data, no JS
</div>
)
}
The mistake I see most often here isn’t misunderstanding the concept — it’s the 'use client' directive creeping upward from where it’s actually needed to an entire page or layout, dragging every component beneath it into client-rendered territory along the way. 'use client' marks a boundary, not a single component: everything imported and rendered underneath that boundary also ships as client JS and loses server-only capabilities, even components that individually have no interactivity of their own. The fix pattern shown here — a Server Component page composing a small, deliberately-scoped Client Component (SearchInput) rather than making the whole page client-rendered — is the difference between shipping a few KB of interactive-input JS versus shipping the entire page’s component tree to the browser; getting this boundary as low/narrow as possible in the tree is genuinely one of the highest-leverage performance decisions in an App Router app, more impactful than most of the tactical fixes elsewhere in this guide.
Next.js Config Optimizations
// next.config.js
module.exports = {
// Compress responses
compress: true,
// Strict mode catches issues early
reactStrictMode: true,
// Experimental optimizations
experimental: {
optimizePackageImports: ['@mui/material', 'lucide-react', 'date-fns'],
// ↑ Tree-shake these packages automatically
},
// Headers for caching static assets
async headers() {
return [
{
source: '/static/:path*',
headers: [{ key: 'Cache-Control', value: 'public, max-age=31536000, immutable' }],
},
]
},
}
optimizePackageImports is worth understanding as a targeted patch for a specific problem: some large libraries (icon sets in particular) export everything from a single barrel file even when individually tree-shakeable, which can confuse a bundler into pulling in more than actually gets used — this option tells Next.js’s compiler to apply more aggressive import rewriting specifically for the listed packages, without needing to manually switch every import to a deep individual-file path (lucide-react/icons/search instead of { Search } from 'lucide-react') throughout the codebase. The Cache-Control header with immutable matters specifically for hashed static assets — files whose name changes whenever their content does (like Next.js’s own build output) can be cached by the browser essentially forever with zero risk of serving stale content, since any real change produces a different filename entirely; applying the same aggressive caching to a non-hashed URL would instead risk serving genuinely outdated content indefinitely.
Performance Checklist
□ Run Lighthouse audit (target: 90+ on all scores)
□ Check Core Web Vitals in Google Search Console
□ Use next/image for ALL images — no <img> tags
□ Set priority on the LCP image
□ Dynamic import for components > 50KB (charts, editors, maps)
□ Add revalidate to data fetches (avoid force-dynamic where possible)
□ Use next/font — eliminate font-related CLS
□ Check bundle analyzer — nothing over 100KB that you don't expect
□ All images have explicit width/height — no CLS
□ Use startTransition for non-urgent state updates
□ Run 'npx knip' — remove unused code
Related Articles
- Next.js App Router: SSR vs SSG vs ISR
- Next.js 15 Internals: App Router vs Pages Router, RSC, Rendering Decisions and the Four Caches