Home/Learn/Next.js/Performance — Bundles and Core Web Vitals

Performance — Bundles and Core Web Vitals

Advanced
Production

Server 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.

Analyse, then split or move to the server
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 is rendering, INP is JavaScript, CLS is layout
// 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 useReportWebVitals

The Next-Specific Wins

Things that only apply because of this framework.

Static, boundary, streaming, bundle — in that order
// 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 Aria
1

What are the most common causes of a large client bundle in an App Router app?

Medium
2

How would you improve LCP on a server-rendered page?

Hard
3

Why is a server-side waterfall harder to spot than a client-side one?

Hard

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.

Loading discussion…