Performance

Read the build output, keep the bundle honest, and know which cache is lying to you.

15of 16
≈ 11Min read
7Parts

The build output is the report card

  • First Load JS is what a visitor downloads before the page is interactive. Under ~100 kB is healthy; 200 kB is felt on a phone.
  • Shared by all is the framework plus anything imported by the root layout. It is the number that punishes a careless import at the top.
  • A route you expected to be ○ showing as ƒ means something read cookies, headers or searchParams — or used no-store.
npm run build — read every line of this table
Route (app)                          Size     First Load JS
┌ ○ /                               1.2 kB          92 kB
├ ● /doors                          2.4 kB          95 kB
├ ƒ /doors/[id]                     1.8 kB          94 kB
└ ƒ /dashboard                     48.1 kB         142 kB
+ First Load JS shared by all                       88 kB

○  Static      prerendered
●  SSG         prerendered with generateStaticParams
ƒ  Dynamic     rendered per request

Where the weight comes from

and how to find it
# npm i -D @next/bundle-analyzer, then
ANALYZE=true npm run build

// the usual culprits
import moment from "moment";              // 70 kB → Intl, or date-fns
import _ from "lodash";                   // 70 kB → lodash-es, or write the one function
import * as Icons from "react-icons";     // the whole set → import the three you use
import Chart from "chart.js";             // 200 kB → load it when it is shown
One import in the root layout is in every page

A date library imported by a header component is downloaded by someone who only read the home page. Check what the shared chunk actually contains before optimising a route.

Load it when it is needed

Worth splitting: charts, editors, maps, modals, anything below the fold and heavy. Not worth splitting: a component that is 3 kB — you have added a request to save nothing.

dynamic imports, with intent
import dynamic from "next/dynamic";

const Chart = dynamic(() => import("./Chart"), {
  loading: () => <ChartSkeleton />,
  ssr: false,                       // it needs the DOM anyway
});

// and for something behind an interaction, load it on the interaction
const openEditor = async () => {
  const { Editor } = await import("./Editor");
  setEditor(() => Editor);
};

The cheapest win is not shipping it

  • A server component ships no JavaScript. Every component that does not need state or events should be one.
  • Move "use client" down to the leaf that needs it.
  • Pass data, not functions: a serialisable prop keeps the boundary where it is.
  • Ask whether the interactivity is needed at all — a <details> element is a free accordion.

The caches, in order

"I changed the data and the page is stale" is nearly always one of these — and the browser one surprises people most, because it survives a client-side navigation back to the page.

LayerWhat it holdsCleared by
Request memoisationOne fetch per renderThe end of the render
Data CacheFetch results between requestsrevalidateTag, revalidatePath, time
Full Route CacheRendered HTML of static routesA deploy, or revalidation
Router CacheVisited routes, in the browserrouter.refresh(), a reload, 30 s

What to measure

  • LCP — the largest thing on screen. Usually an image: give it priority and the right sizes.
  • CLS — layout shift. Reserve space for images, embeds and anything that streams in.
  • INP — how quickly the page responds to a tap. Usually too much JavaScript on the main thread.
  • Measure on a throttled phone profile, not on your laptop. next build plus Lighthouse, and real field data if you have it.

Before optimising anything

Build it, read the table, open the analyser, and throttle the network. Three of the four times, the answer is an image that is not using next/image, a library that should have been three lines, or a "use client" at the top of a file that never needed it.

Practice