Auditing and Replacing Heavy Dependencies

Most bundles are not uniformly heavy. They are dominated by a handful of packages: a date library that bundles every locale, a validation library that ships a full parser, an HTTP client that duplicates functionality the platform already provides, an icon set imported through a barrel that defeats every optimisation applied downstream. In a typical mid-scale application, the five heaviest packages account for 40–60% of the shipped JavaScript, and the top one alone frequently exceeds the entire application’s own code.

That concentration is what makes dependency auditing the highest-leverage work in the whole tree-shaking discipline. Removing dead code from your own modules might recover a few kilobytes; removing one 78 KB date library that is used for two format calls recovers more than a month of careful refactoring elsewhere. The section overview at advanced tree-shaking and dependency optimization sets the target of an initial payload under 150 KB gzipped and a tree-shaking efficiency above 85%; neither is reachable while a single dependency owns a third of the bundle.

This page is the repeatable procedure: how to rank dependencies by what they actually cost, how to tell weight from waste, how to choose between configuring, narrowing, deferring, and replacing, and how to keep the win once you have it.

Rank by shipped bytes, not by install size

The single most common auditing mistake is ranking dependencies by their published package size or their node_modules footprint. Neither predicts what ships. A 4 MB package that tree-shakes down to 3 KB is not a problem; a 90 KB package that ships in full because of one barrel import is.

The only ranking that matters is byte attribution against the production bundle, derived from source maps as described in attributing bundle bloat with source maps. That process walks every mapping segment in the built chunk, sums the generated bytes tracing back to each original file, and rolls those up per package — giving a ranking of what your users actually download.

Install Size Does Not Predict Shipped Bytes Five packages compared: some with large install footprints contribute almost nothing to the bundle, while a mid-sized package contributes nearly all of its weight because of how it is imported. Per-package contribution to the production chunk (gzipped KB) date library 78 KB shipped — 96% of install icon set 51 KB shipped — barrel import validation lib 29 KB shipped ui framework 12 KB shipped — 4 MB installed build helper 2 KB shipped — dev dependency Rank by the dark bars — install footprint tells you nothing about what the user downloads

Separate weight from waste

Two dependencies contributing 60 KB each can need completely different fixes. The distinction is reach: how much of the package your code actually exercises.

High reach, high weight. The package is large and you use most of it. Nothing about your import shape is wrong. The only levers are deferring the package behind a dynamic import, or replacing it with something smaller that covers the same ground.

Low reach, high weight. You call three functions and ship the whole library. This is the classic tree-shaking failure — a CommonJS package the bundler cannot statically analyse, a missing or wrong sideEffects declaration, or a barrel import that reintroduces everything. Fix the import shape and the weight often collapses without changing a single dependency.

Measuring reach is straightforward with a build that keeps module concatenation off: count the modules the bundler retained from the package, compare that against the package’s total module count, and read the ratio. A package where 4 of 180 modules are retained is behaving correctly; a package where 178 of 180 are retained while your code calls one function is not — the cause is usually one of the patterns described in configuring sideEffects for optimal tree-shaking or refactoring barrel files to reduce bundle bloat.

Reach × Weight: Which Fix Applies A matrix: low reach and high weight means fix the import shape; high reach and high weight means defer or replace; low weight means leave it alone regardless of reach. Choose the fix from reach × shipped weight shipped weight → Low reach · high weight Fix the import shape first sideEffects · barrels · CJS interop High reach · high weight Defer behind a dynamic import, or replace with a smaller package Low reach · low weight Leave it — already optimal High reach · low weight Leave it — earning its bytes reach = share of the package's modules your code actually retains →

Bundler configuration for the audit

Both bundlers can emit the raw data the audit needs. The point of these configurations is not to optimise anything — it is to make the build legible so the ranking is trustworthy.

// webpack.config.js — Webpack 5: a build shaped for auditing, not for shipping
module.exports = {
  mode: 'production',
  devtool: 'hidden-source-map',   // maps for attribution, not referenced by the bundle
  optimization: {
    // Concatenation merges modules into one scope, which destroys per-module
    // attribution. Turn it off for the audit build only.
    concatenateModules: false,
    usedExports: true,            // record which exports survived, per module
  },
  stats: {
    // Emit the module-level detail the reach calculation needs.
    modules: true,
    reasons: true,                // why each module was retained
    usedExports: true,
  },
};
// vite.config.js — Vite 5+: the equivalent audit build
import { defineConfig } from 'vite';

export default defineConfig({
  build: {
    sourcemap: 'hidden',
    rollupOptions: {
      output: {
        // One module per chunk boundary is wrong for production but ideal for
        // attribution: it makes each package's contribution directly visible.
        minifyInternalExports: false,
      },
      treeshake: {
        // Report what Rollup considered removable without actually removing it,
        // so reach can be computed against the unpruned graph.
        moduleSideEffects: true,
      },
    },
  },
});

Run the audit build alongside the real production build, never instead of it. The numbers you report must come from the shipping configuration; the audit build exists only to explain them.

The replacement decision

Once a package is identified as high reach and high weight, four options remain, in ascending order of cost and risk.

Four Options, Ascending Cost Configure, import narrowly, defer, replace — each step costs more engineering time and carries more behavioural risk than the one before it. Try these in order — stop at the first that works 1. Configure free, reversible 2. Import narrowly no behaviour change 3. Defer one afternoon, low risk 4. Replace migration + test coverage low cost, low risk high cost, high risk Replacement is justified only at 50% fewer shipped bytes, or 30 KB gzipped absolute
  1. Configure it. Many heavy packages ship a lighter build behind a resolution alias or a plugin option — a modular entry point, a locale-free build, a browser field pointing at a smaller artifact. This is free and reversible.
  2. Import it more narrowly. Deep imports of the specific submodules you use, rather than the package root, bypass a barrel entirely. The mechanics are the same as those covered in optimizing lodash and utility library imports.
  3. Defer it. If the package only matters for a feature that not every session reaches, move it behind a dynamic import. It stops being a bundle-size problem and becomes a loading-strategy problem, which is a much better problem to have.
  4. Replace it. The only option that changes behaviour, and the only one that needs a migration plan and test coverage.

Replacement is worth it when the candidate is at least 50% smaller in shipped bytes, covers every API your code calls, and does not drag in a comparable transitive subtree of its own. Verify the third condition explicitly: a “lightweight” replacement that depends on three polyfill packages can ship more bytes than the thing it replaced.

// date-format.js — isolate the dependency behind your own module boundary
// so a future replacement touches one file instead of two hundred call sites.
import { format, parseISO } from 'date-fns';

export function formatDate(value, pattern = 'yyyy-MM-dd') {
  return format(typeof value === 'string' ? parseISO(value) : value, pattern);
}

export function formatDateTime(value) {
  return formatDate(value, "yyyy-MM-dd HH:mm");
}

That indirection is the single most valuable habit in dependency management. It costs one file, and it converts every future migration from a codebase-wide refactor into a localized change with a stable test surface.

Quantified impact

  • Top-five concentration: 58% → 24% of shipped JavaScript. A completed audit pass on a typical mid-scale application redistributes weight so no single package dominates the bundle.
  • Date handling: 78 KB → 9 KB gzipped. Replacing an all-locales date library with a modular one, importing only the two functions in use, is the most reliably large single win available in most codebases.
  • Icon weight: 51 KB → 4 KB gzipped. Switching from a barrel import to per-icon module imports removes the entire unused set, with no behavioural change at all.
  • Tree-shaking efficiency: 71% → 89%. Fixing import shape on the low-reach packages moves the ratio past the 85% threshold the section overview sets as the target.
  • Audit cost: 3–5 engineer-days for the first pass, under an hour for each subsequent one. The expensive part is building the attribution workflow; once it exists, re-running the ranking is a single command.

Common pitfalls

Optimising the wrong end of the list. Teams frequently start with the package they find most annoying rather than the one that ships the most bytes. The ranking exists precisely to prevent this; work strictly top-down.

Trusting the package’s own size claims. A published “2 KB minified and gzipped” figure describes the package’s own code, in isolation, with no dependencies counted. Measure it in your bundle, in your configuration, with your import shape.

Replacing without an isolation layer. Swapping a dependency across four hundred call sites in one pull request makes the change unreviewable and un-revertable. Introduce the wrapper module first as a no-op refactor, then swap the implementation behind it.

Ignoring the transitive subtree. A replacement that is smaller on paper can be larger in practice if it pulls in dependencies you did not previously have. Always re-run the full attribution after the swap, not just the line item for the replaced package.

Letting the removed package return. Without a lint rule, a removed dependency comes back within a quarter through a new team member, a copied snippet, or a transitive dependency of something else. The reintroduction is silent because the package is still in the lockfile.

Auditing a development build. Development builds retain code that production strips, so the ranking is wrong in ways that vary per package. The audit build must be mode: 'production' or Vite’s build command, never the dev server.

Verification workflow

  1. Establish the baseline. Record the gzipped size of every chunk and the per-package attribution table before any change. Without this, later comparisons are guesses.

  2. Change one dependency at a time. A branch that swaps three packages produces one number and no attribution. One package per branch keeps every result interpretable.

  3. Re-run the attribution, not just the total. Confirm the replaced package is at zero bytes and that no new package appeared in its place at comparable size.

  4. Diff behaviour, not just size. Run the test suite plus a manual pass over the features that used the package. Date and number formatting, in particular, fail silently across locales — the output changes without throwing.

  5. Check the chunk graph. Confirm the replacement did not land in a different chunk than the original, which would move bytes rather than remove them. The treemap workflow in visualizing bundle composition with analyzers makes this immediate.

  6. Add the guardrails. A lint rule banning the old package by name, plus a size budget on the affected chunk, as described in enforcing performance budgets in CI.

FAQ

How do I know whether a dependency is heavy because of its size or because of how we import it?

Compare the package’s shipped bytes against the number of exports your code actually references. If a 90 KB package contributes 88 KB while your code calls three of its functions, the problem is import shape or a missing side-effect declaration, and the fix is configuration rather than replacement. If it contributes 88 KB and your code genuinely exercises most of its surface, the package is simply large for what it does, and only a different package or a deferred load will change the number.

Is replacing a dependency always better than deferring it?

No — deferring is cheaper, safer, and often sufficient. Moving a heavy package behind a dynamic import costs an afternoon and no behavioural risk, and it removes the bytes from the critical path entirely for sessions that never reach the feature. Replacement changes semantics, needs test coverage, and carries migration risk across every call site. Reserve replacement for packages that sit on the critical path in every session, where deferral has nothing to defer to.

How much smaller does a replacement have to be to be worth it?

A useful bar is a 50% reduction in that dependency’s shipped bytes, or 30 KB gzipped absolute, whichever is larger. Below that, the engineering cost and the regression risk of touching every call site rarely pay back, and the same effort spent on deferring a different package usually yields more. The exception is a dependency that also drags in a large transitive subtree, where the headline number understates the saving.

How do I stop a removed dependency from coming back?

Two mechanisms, both cheap. First, a lint rule that bans importing the package by name, so a new call site fails review rather than shipping. Second, a size budget on the affected chunk in CI, so even an indirect reintroduction through a transitive dependency shows up as a failed build. The lint rule catches intent, the budget catches accidents, and neither on its own is sufficient.