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.
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.
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 lazyThe four dependencies that dominate most bundles
| Dependency | Typical cost | What we do instead |
|---|---|---|
| Moment.js or a full date library | 70–290 KB with locales | Intl.DateTimeFormat for formatting; date-fns imported per function where arithmetic is needed |
| A whole icon package | Hundreds of KB if imported wholesale | Named imports only, verified in the analyser — a barrel file can defeat tree shaking entirely |
| Lodash imported as a namespace | ~70 KB | Per-method imports, or native equivalents; most usage is two functions that ship in the language now |
| A charting or editor library | 150–500 KB | Dynamic 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.
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'));- 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.
{
"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" }
]
}