Performance — Bundles and Core Web Vitals
AdvancedServer components already removed most client JavaScript. What is left is the boundary you drew, the libraries you import, and whether the LCP element is prioritised.
Overview
Next gives you a strong starting position — code splitting per route, server components that ship no code, images and fonts optimised by default — so performance work here is less about clever techniques than about not undoing those defaults. The three ways teams undo them are consistent: a `use client` too high in the tree, a heavy library imported where it did not need to be, and an unprioritised hero image. Measuring first still matters, because the fix for a slow LCP and the fix for a slow INP have nothing in common.
Finding the Weight
Analyse before optimising; the cause is usually one import.
npm i -D @next/bundle-analyzer
// next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true',
})
module.exports = withBundleAnalyzer(nextConfig)
ANALYZE=true npm run build
// What it usually shows, in order of frequency:
// a date library (moment) in the shared chunk
// an icon set imported as a barrel
// a chart or editor library on a page most people never open
// two copies of a library from mismatched versions
// a client component pulling in a server-side dependency
// Split what is heavy and rarely used:
const Editor = dynamic(() => import('@/components/Editor'), {
ssr: false, // it touches window
loading: () => <EditorSkeleton />,
})
// Import per icon, never the barrel:
import { Search } from 'lucide-react' // one icon
import * as Icons from 'lucide-react' // the whole set
// And the Next-specific one: check whether the code needs to be on
// the client at all. Moving a component back to the server removes
// it from the bundle entirely, which beats any splitting.The Three Vitals in a Next App
Each has a different cause and a different fix.
// LCP < 2.5s — when the main content appears
// Usual causes: a dynamic route that could have been static, an
// unoptimised hero, a slow upstream API blocking the render.
// Fixes: prerender the route, priority on the hero image, stream
// the slow part behind Suspense so the shell paints first.
<Image src={hero} alt="" priority sizes="100vw" />
// INP < 200ms — responsiveness to interaction
// Usual causes: too much client JavaScript hydrating, a heavy
// re-render on input, a long task on the main thread.
// Fixes: shrink the client boundary, useTransition for expensive
// updates, virtualise long lists.
const [isPending, startTransition] = useTransition()
startTransition(() => setFilter(value))
// CLS < 0.1 — how much the page jumps
// Usual causes: images without dimensions, a font swap, content
// injected above what is being read, a skeleton that does not match.
// Fixes: next/image everywhere, next/font (metric-matched), and
// skeletons shaped like the real thing.
// Measure in the field, not on your laptop:
// app/layout.tsx
import { SpeedInsights } from '@vercel/speed-insights/next'
// or roll your own with useReportWebVitalsThe Next-Specific Wins
Things that only apply because of this framework.
// 1. Make the route static. A prerendered page has no function
// invocation, no cold start and comes from a CDN. This is worth
// more than any client-side optimisation, and the build output
// tells you which routes qualify.
// 2. Keep the client boundary low. Every component above a
// 'use client' that could have stayed on the server is bundle
// weight for no benefit.
// 3. Stream. Suspense boundaries mean the slowest query stops
// deciding time-to-first-byte.
// 4. Parallelise server fetches with Promise.all — a waterfall on
// the server is invisible in the network tab and just as slow.
// 5. Prefetching is on by default in production, which is most of
// why navigation feels instant. Do not disable it globally to
// "save bandwidth" without measuring.
// 6. optimizePackageImports for large barrel-file libraries:
experimental: { optimizePackageImports: ['lucide-react', 'date-fns'] }
// The order to work in: static first, then boundary, then bundle,
// then micro-optimisation — which you will rarely reach.Key Points to Remember
- 1Analyse the bundle before optimising — the cause is usually one heavy import or a barrel file
- 2Moving a component back to the server removes it from the bundle entirely, which beats code splitting
- 3LCP is a rendering problem, INP is a JavaScript problem and CLS is a layout problem — different fixes
- 4Making a route static removes the function invocation and cold start altogether
- 5Server-side waterfalls are invisible in the network tab; parallelise independent fetches with Promise.all
Interview Questions
Sign in to ask AriaWhat are the most common causes of a large client bundle in an App Router app?
How would you improve LCP on a server-rendered page?
Why is a server-side waterfall harder to spot than a client-side one?
Ask Aria about Performance — Bundles and Core Web Vitals
Your personal AI tutor — ask anything about this concept
Revision Status
Personal Notes
Sign in to save personal notes for this topic.
Discussion
Sign in to join the discussion.