Skip to content
Security

Bundle size

Reading the analyser properly, the dependencies that quietly dominate a bundle, splitting that helps rather than fragments, and holding the size in CI.

Written forEngineers on a React application that has grown for a couple of years
Reading time10 min read
Last reviewed2026-09-05

JavaScript is the most expensive thing a page ships, because it is paid for three times: transferred, parsed and executed. A 300KB image and 300KB of JavaScript cost roughly the same to download and nothing like the same afterwards — and the gap widens on exactly the mid-range devices most of your users hold.

Measure the right number

The figure that matters is what a first-time visitor executes before the page is interactive, not the total build output. A dashboard route that ships 800KB is irrelevant to someone landing on a case study.

bash
ANALYZE=true next build

# Read it in this order:
#  1. First Load JS shared by all   <- the number to defend
#  2. The largest single dependency  <- usually one of four suspects
#  3. Anything appearing in every chunk <- a shared import that should be lazy
Run the analyser on a production build. Development bundles are meaningless here.

The four dependencies that dominate most bundles

DependencyTypical costWhat we do instead
Moment.js or a full date library70–290 KB with localesIntl.DateTimeFormat for formatting; date-fns imported per function where arithmetic is needed
A whole icon packageHundreds of KB if imported wholesaleNamed imports only, verified in the analyser — a barrel file can defeat tree shaking entirely
Lodash imported as a namespace~70 KBPer-method imports, or native equivalents; most usage is two functions that ship in the language now
A charting or editor library150–500 KBDynamic import at the point of use — these are almost never needed for first paint

Split where the user changes context

Code splitting helps when the split boundary matches a real boundary in the user's journey. Splitting into forty chunks that all load on the same screen makes things worse: more requests, more overhead, no less code.

tsx
const Chart = dynamic(() => import('@/components/Chart'), {
  ssr: false,                       // it needs the DOM anyway
  loading: () => <ChartSkeleton />, // same height, so no layout shift
});

const ExportDialog = dynamic(() => import('@/features/ExportDialog'));

// Rendered only for a role most users do not have
const AdminPanel = dynamic(() => import('@/features/AdminPanel'));
Three genuine boundaries: below the fold, behind an interaction, and admin-only.
  • The App Router splits per route already. Most remaining wins are within a route, not between routes.
  • A component behind a modal, a tab or a permission is the ideal candidate — the code is genuinely optional.
  • Give every lazy component a skeleton of the same height, or you have traded bundle size for layout shift.
  • Do not lazy-load anything above the fold. The extra request costs more than the bytes saved.

The largest lever: do not ship it at all

Server components change the arithmetic more than any splitting strategy, because a component that never reaches the client ships no JavaScript at all — including its dependencies. A markdown renderer, a date formatter, a syntax highlighter used once on a page can each disappear from the bundle entirely by moving the work to the server.

Hold the line in CI

Bundles do not regress in one commit; they grow by ten kilobytes at a time until somebody notices a year later. A budget check in CI turns that into a conversation at the moment of the change, when the person who added the dependency still remembers why.

json
{
  "size-limit": [
    { "path": ".next/static/chunks/main-*.js", "limit": "90 kB" },
    { "path": ".next/static/chunks/framework-*.js", "limit": "48 kB" },
    { "path": ".next/static/css/*.css", "limit": "40 kB" }
  ]
}