Finding what actually bloats a Next.js bundle
A client's Next.js dashboard takes 4.2 seconds to become interactive on a Redmi 9 Power over a flaky 4G connection. The Lighthouse report on our office WiFi says 1.1 seconds. The gap is the whole problem.
We start with `@next/bundle-analyzer`. It wraps the build and drops two HTML treemaps — one for the client, one for the server. The client treemap for this project is 340 KB gzipped. That is not catastrophic, but it is 340 KB before a single line of application code runs, and the phone is spending most of those 4.2 seconds parsing and evaluating it.
The treemap shows three things that stand out.
**Moment.js, 67 KB gzipped, used in exactly one place.** A date-range picker component imports `moment` for date formatting. The picker itself is a third-party component from a UI library, and it pulls moment as a peer dependency. The actual formatting call is `moment(date).format('DD MMM YYYY')` — 67 KB of locale data to produce a string that `Intl.DateTimeFormat` produces natively.
We replace the picker with a lighter one that accepts a `value` and an `onChange` and does its own formatting. The formatting moves to a 40-line utility using `Intl`. We drop moment entirely. Savings: 67 KB gzipped, 210 KB raw. The phone now parses 210 KB less JavaScript before first paint.
**Lodash, 23 KB gzipped, used for `debounce` and `cloneDeep`.** The treemap shows the full lodash entry point, not the per-method builds. Someone wrote `import { debounce } from 'lodash'` and the bundler included the entire library because tree-shaking lodash is unreliable — it exports from a single index file with side-effect-laden module patterns.
Two options here. Switch to `lodash-es` and import named methods, which tree-shakes correctly. Or replace two calls with native code. `debounce` is 15 lines. `cloneDeep` on our data shapes — plain objects with arrays of primitives — is `structuredClone` in every browser we target. We pick the second option and remove lodash. Savings: 23 KB gzipped.
**A charting library, 94 KB gzipped, loaded on a page the user sees after login.** The dashboard landing page renders a summary view with three charts. The charts are the first thing users see, so the library is in the critical path. But the charts are not interactive on first load — they are read-only snapshots. The interactive version loads on click.
We split this. The initial render uses a static SVG generator — the same data, the same visual, but server-rendered as an SVG string. The charting library loads only when the user clicks to interact with a chart, via `next/dynamic` with `ssr: false`. The SVG weighs 3 KB. The 94 KB library loads on demand, after the user has already seen the data and decided to engage with it.
This is a tradeoff, not a free win. The static SVG does not support hover tooltips, zoom, or the animated transitions the library provides. We made the charts less flashy on first load. The client agreed because their users — field supervisors checking overnight numbers — care about the numbers, not the transitions.
**What we did not act on.** The treemap showed `react-dom` at 45 KB gzipped. That is not reducible without leaving React, which is not on the table. It also showed `next/dist/client` runtime at 38 KB. That is the framework tax. We accept it.
A smaller finding: `clsx` at 1.2 KB. We could replace it with a template literal and a filter. The savings do not justify the readability loss. We left it.
After the three changes, the client bundle is 156 KB gzipped. First interactive on the Redmi drops to 2.3 seconds on the same connection. The remaining time is network round-trips for data, which is a different problem.
The bundle analyzer stays in `devDependencies`. We run it before every release. The treemap changes as dependencies drift and new code lands. Catching a 40 KB regression before it ships is cheaper than explaining why the dashboard got slower after the fact.