writing

Cutting 120 KB from a JavaScript bundle — a worked investigation

A walkthrough of finding and fixing three common culprits — a heavy date library, a whole icon pack import, a duplicated dep — then locking the saving in with a budget.

The numbers below are a worked illustration, not a case study from a specific team. The scenario is realistic — this kind of creep is common enough that the specific causes will look familiar — but treat them as a concrete example to reason from rather than a benchmark to compare against.

The setup

An app's main bundle sat at roughly 210 KB gzipped at the start of a quarter. By the end, it had grown to around 340 KB. No single change caused it. The spike arrived in the history chart as a staircase: two meaningful jumps separated by about six weeks of flat, then another modest climb. Without per-commit tracking, the growth would have looked like one undifferentiated mass to untangle. With it, the staircase gave three specific commits to inspect.

Finding the culprits

Opening the treemap at the first jump, the vendor subtree had gained a new block: date-fns-tz, about 38 KB gzipped, dragged in as a transitive dependency of a calendar component added during a feature sprint. The component itself used one function from the library — formatInTimeZone — but the entire package came along for the ride because the consuming code imported from the package root rather than the specific subpath.

The second jump was subtler. An icon set had been added to handle a new set of status indicators. The treemap showed @heroicons/react at around 41 KB. The component importing icons was doing this:

import { CheckCircleIcon, ExclamationIcon } from '@heroicons/react/solid';

That looks like a named import, but the barrel file in /solid re-exports every icon in the set, so tree-shaking had nothing to eliminate. The whole solid directory shipped.

The smaller third climb was a version mismatch. lodash-es appeared twice in the treemap under slightly different resolved paths, meaning two versions of it had ended up in the bundle simultaneously — one from the app's own package.json and a second from a dependency that pinned a different minor version. The webpack stats reasons graph made this straightforward to confirm: both entries had different issuer chains.

The fixes

Each cause had a narrow, targeted fix.

For date-fns-tz, the transitive import was replaced with the browser's Intl.DateTimeFormat API, which handles formatInTimeZone equivalently for the limited use case in question. That dropped the whole package from the bundle.

For the icon set, the imports were changed to use the deep-import path:

import CheckCircleIcon from '@heroicons/react/solid/CheckCircleIcon';
import ExclamationIcon from '@heroicons/react/solid/ExclamationIcon';

With named subpath imports, tree-shaking can eliminate the unneeded icons. The icon contribution shrank from 41 KB to under 3 KB.

For the lodash duplicate, a resolutions field in package.json (or overrides in npm 8+) pinned both the direct and transitive dependency to the same version, collapsing two copies into one.

{
  "resolutions": {
    "lodash-es": "4.17.21"
  }
}

After all three changes, the bundle measured around 220 KB gzipped — a net reduction of roughly 120 KB from peak, and about 10 KB smaller than it had been at the start of the quarter before the regressions landed.

Making the result permanent

Measuring is not enough on its own. The same patterns can return in the next sprint: a new developer unfamiliar with the icon import convention, another transitive dep with a large payload, a dependency range that allows a new minor with a version mismatch.

The durable fix is a CI budget that fails the build if the bundle crosses a threshold. For this app, a budget of 240 KB gzipped leaves headroom for legitimate growth while catching the kind of creep that just happened. The setup is a single push in CI:

curl -sS "https://dendrobundle.com/api/push?branch=$BRANCH&commit=$SHA" \
  -H "Authorization: Bearer $BUNDLE_TOKEN" \
  -H "Content-Type: application/json" \
  -d @dist/webpack-stats.json

When the budget is set on the project, dendrobundle sends a regression alert and marks the build over-budget if the threshold is crossed. The CI integration guide has the full GitHub Actions and GitLab CI snippets.

The investigation described above would have taken minutes rather than days if the budget had been in place at the start of the quarter. Two of the three regressions would have been caught at the PR that introduced them; the third would have needed a follow-up nudge, but only after a very small drift rather than a 60 KB one.

Where to start

If you have never looked at your bundle at a module level, the free analyzer at /analyze takes a webpack stats file, an esbuild metafile, or a rollup-stats JSON and renders the treemap with no account needed. The supported formats page covers how to generate each one. If you want per-commit history and a CI gate, the Community tier on dendrobundle covers three projects at no cost.

The common thread across all three fixes above was the same: the problem was not visible until something was measuring it at the right level of detail, on the right cadence. Bundle size is cheap to track and expensive to ignore.