Web Development

Bundle Analysis: Find the Dependency That Doubled Your JS

Practical guide to identifying which npm package is inflating your JavaScript bundle, using source maps, treemaps, and bundle analyzers.

Mohammed Saqib10 min read
Flat lay of black office supplies on white background, including laptop and notepad.
Photo by Yan Krukau on Pexels · Pexels License

Somewhere in your dependency tree there is a package costing you more than every file you wrote by hand. The build output doubled, the first load got slower, and the usual suspects all deny it. This is how I find that package, confirm it with evidence, and stop it from coming back.

The First Sign: A Suspiciously Large Build Output

The first sign is normally a CI log line or a PR comment, not a hunch. If you don't already record the gzipped size of every entry chunk on every commit, you are flying blind; the page speeds up over weeks and you have no idea which commit broke the trend. Some teams post the reported sizes to the PR, others compare two artifacts with a diff script. Either way, you need a baseline before you need a diagnosis.

Once you have a build that looks fat, generate a picture of it. The two tools I reach for are the webpack bundle analyzer for webpack projects and vite-plugin-visualizer for Vite. Both produce an interactive treemap where each rectangle is a module and its area corresponds to byte size. Run it against a production build, never a dev build; dev mode skips minification and tree shaking so the proportions lie.

// vite.config.js
import { defineConfig } from 'vite';
import { visualizer } from 'rollup-plugin-visualizer';
 
export default defineConfig({
  plugins: [
    visualizer({
      filename: 'dist/stats.html',
      gzipSize: true,
      template: 'treemap',
    }),
  ],
});

Open the report and your eyes will land on the biggest rectangle. In a codebase with sane first-party code, that rectangle usually sits several levels deep under node_modules. Heavier bundles don't just cost bandwidth; they extend parse and evaluation time on the main thread, and that shows up later as INP regressions — a problem I've written about here.

Reading the Treemap: Separating First-Party from Third-Party

The treemap answers one question at a glance: where does the weight actually live? In the webpack bundle analyzer you can collapse webpack:// and drill into node_modules; in the Vite report, the same folder tree is at the top level. Ignore your own src at first. If your entire application occupies a third of the map and one dependency occupies the other two thirds, this is not a refactoring problem, it is a procurement problem.

A treemap makes it easy to find a large dependency, but it has a resolution limit: the smallest visible rectangle is still a whole module. For a more granular breakdown, feed the actual JS file and its source map to source-map-explorer. It attributes bytes in the minified output back to the original modules, sometimes down to individual named exports when the map carries sourcesContent. One caveat: it reports raw, pre-minification module sizes, so do not compare its numbers to the gzip figure on your CDN. Use it to compare relative contributions between modules.

The other thing to check at this level is data that ships with the package but isn't really code. Locale files are the classic case: a date formatting library can look lean until it loads every regional variant. The same pattern appears in timezone databases, icon sets, and code-split language dictionaries. If the treemap shows a block of modules that share a naming convention like locale/ or locales/, you have found a trim point before you have found a bug.

Common Culprits: Moment.js, Lodash, and Other Heavy Hitters

Some packages are heavy by default, not by accident, and they keep showing up in production bundles for the same reasons every time.

Moment.js is the reference example. A single import moment from 'moment' pulls in the core library plus every locale unless you configure otherwise, and that combination can run to hundreds of kilobytes in your bundle. If you still need Moment while migrating, keep only the locales you actually support with moment-locales-webpack-plugin:

// webpack.config.js
const MomentLocalesPlugin = require('moment-locales-webpack-plugin');
 
module.exports = {
  plugins: [
    new MomentLocalesPlugin({
      localesToKeep: ['en', 'de'],
    }),
  ],
};

But the plugin is a stopgap; the structural fix is replacing Moment with date-fns or Luxon, or dropping to the native Intl API for formatting. npm package size on disk is irrelevant; what matters is what lands in your entry chunk. Intl.DateTimeFormat is already shipped by the browser and costs you zero bytes from your own server.

Lodash is the second recurring offender, and the cause is almost always the import style. import _ from 'lodash' is a request for the entire library. Switching to lodash-es with named imports, or per-path imports like import debounce from 'lodash/debounce', gives the bundler a chance to drop everything else. lodash-es marks itself side-effect free, so a bundler that respects the sideEffects field can eliminate unused functions.

The third, more surprising culprit is polyfills. core-js and regenerator-runtime are injected transpilation output, so they feel like infrastructure rather than a choice. But their size is entirely determined by your browserslist config: every legacy browser target you keep in that file is a set of features the bundler must defensively polyfill. Audit the targets, and treat dropped browser support as a real bundle reduction.

Duplicate Modules and Version Mismatches

Duplicate dependencies are the most common cause of a bundle that is larger than the sum of its package.json file. Two different major versions of the same utility library get hoisted to different folders, both get bundled, and your output contains the same functions twice.

The first command to run is the one that shows you the tree: npm ls <package> in npm, yarn why <package> in Yarn. Both print the dependency chain that leads to each installed version, and the output frequently surprises people — a utility library you never import directly is pulled in by two transitive dependencies, each pinning a different range.

When the versions are API-compatible, force the package manager to install one copy. npm projects use the overrides field; Yarn uses resolutions. Both tell the resolver "whatever anyone asks for, give them this version":

{
  "overrides": {
    "lodash": "4.17.21"
  }
}

Be careful with major-version overrides: some transitive dependency may rely on behavior that changed between majors. Test the paths that use the dependent package, not just the app's own usage. If an override is too risky, webpack's resolve.alias option can redirect all requests for a module to a single copy, but that is a surgical tool that can mask real incompatibilities. The old DedupePlugin is gone, and the supported strategy today is forcing resolution at the package manager level.

Tree-Shaking Failures: When Dead Code Elimination Doesn't Work

You can fix the dependency and still ship unused code because the bundler was never allowed to remove it. Tree shaking works on ES modules with static imports, but it is not unconditional: the bundler only drops a module when it can prove the module has no side effects. The proof comes from the package's package.json.

The field is sideEffects. When it is missing or set to true, webpack must assume importing the module might execute arbitrary code, so it keeps everything. When the package sets sideEffects: false, the bundler is free to discard exports no one uses. Most tree shaking issues come down to metadata the package author forgot to declare, and the standard reference — webpack's guide on tree shaking — covers exactly this mechanism. A package that ships CommonJS instead of ES modules cannot be tree-shaken at all; there are no static exports to analyze.

Your own code can cause the same problem through barrel files. An index.js that re-exports ten modules looks like a convenience, but when another module does import { thing } from './utils', the bundler has to evaluate the barrel before it can prove thing is the only export needed. Deep imports — import { thing } from './utils/thing' — remove the ambiguity entirely and work reliably across bundlers.

To verify that elimination actually happened, emit webpack's stats JSON and inspect it:

npx webpack --mode production --json > stats.json
jq '.modules[] | select(.name | contains("node_modules/lodash")) | {name, usedExports}'

Webpack records a usedExports field per module in that JSON. An array means tree shaking ran and names the exports that survived; a null value means every export was kept. If you find a module you expected to be partially used sitting in the output with a full export list, you have your tree-shaking failure in writing.

Tools That Go Beyond Treemaps: Bundle Buddy and Size Limit

Treemaps tell you where the weight is; they don't tell you why a module is reachable. For duplicate code across entry points, bundle-buddy compares the modules included by different entry chunks and flags the ones shared between them, which surfaces accidental duplication that a single-page treemap misses.

size-limit works on a different axis: budgets. You declare a maximum size per entry and it becomes a CI check, not a code review negotiation. Run it on every PR and the machine enforces the number: npx size-limit reads the config, measures the built files, and exits nonzero on an overage.

For the "why is this here at all" question, whybundled is the fastest tool: given a webpack stats file and a package name, it prints the import chain that pulled the package into the bundle. That output is precisely what you need when someone claims a dependency is vital — the chain either shows a direct top-level import, or it points at a transitive dependency that can be replaced.

Tool Question it answers Where it runs
webpack-bundle-analyzer Where does the weight sit across the whole bundle? Local or CI artifact
source-map-explorer Which module or export contributes the bytes inside a file? Local, against a production build
size-limit Did this change stay under the agreed budget? CI, on every PR
whybundled What import chain pulled this package into the bundle? CLI against a webpack stats file

That stack covers detection, diagnosis, and enforcement. Most teams only have the first tool installed, which is why the same heavy dependencies come back quarter after quarter.

Fixing the Bloat and Preventing Regression

Replacing the offending dependency with a lighter one is the obvious move; the less obvious part is choosing the right replacement. Formatting a date in a en-US application does not need Moment, date-fns, or Luxon — Intl.DateTimeFormat covers it with zero bundle cost. Utility functions you wrote around Lodash can usually be replaced by a few ES2015+ methods instead of importing the library. Before you commit to a swap, measure the trade-off rather than trusting the comparison table, the same break-even logic that applies when you weigh partial hydration's break-even point applies to a date library.

The fix, however, only survives if it is structural. That means three things.

First, a size budget in CI. size-limit fails the build when an entry exceeds its declared limit, which forces a debate about bundle growth at the exact moment it happens. Budgets force you to reduce bundle size before merging, not after the bundle has already regressed. The config is explicit enough to read like a contract:

{
  "size-limit": [
    {
      "path": "dist/assets/index-*.js",
      "limit": "180 KB",
      "gzip": true
    }
  ]
}

Second, an import ban on the packages you have already removed. ESLint's no-restricted-imports rule, configured with the offending module names, turns a new import moment from 'moment' into a lint error at the moment it is written. This is the only defense that survives personnel changes; a senior engineer who knows not to import Moment can leave, and the rule still holds.

Third, keep the diagnostics visible. Leave the bundle analyzer report as a CI artifact on every release. The next time a bundle grows, the report for that exact commit already exists and nobody has to reproduce the build from memory.

Key takeaways

  • Start with a production treemap; it shows you the largest dependencies before you argue about your own code.
  • Run npm ls <package> to find duplicate versions, and force a single copy with overrides or resolutions.
  • Tree shaking only works when packages declare sideEffects: false and you avoid barrel files; confirm with usedExports in webpack's stats.
  • Enforce budgets in CI and ban known-heavy imports with ESLint, because bundle bloat is a regression that comes back without structural checks.

Frequently asked questions

How do I know if a dependency is the cause of my bundle size increase?
Run a bundle analysis tool like webpack-bundle-analyzer on your production build. Compare the output before and after adding the dependency. The tool shows a treemap where each rectangle’s area corresponds to byte size. Look for large unfamiliar rectangles under node_modules.
What if my bundle analyzer shows large chunks from my own code, not dependencies?
That usually means your application code is large, not a specific dependency. Use code splitting (dynamic imports) to split routes or components. You can also profile which modules are used often; consider lazy loading rarely used features.
Can tree-shaking be guaranteed with modern bundlers?
Not fully; it depends on how packages are written. Ensure dependencies have sideEffects: false in their package.json (or use the module field for ES modules). Avoid barrel files (re-exporting from index.js) as they prevent tree-shaking. Tools like Webpack’s optimization.usedExports help, but always verify with a bundle analyzer.
#bundle-analysis#javascript#performance#webpack#build-tooling
Share

Keep reading