Without a systematic way to measure bundle impact you will not know when a refactor or a new dependency silently doubles your library’s footprint. This page shows how to pair webpack-bundle-analyzer with Bundlephobia’s CLI to set authoritative gzip-size baselines, surface optimizationBailout flags that expose tree-shaking failures, and block size regressions before they reach the npm registry — all in the context of optimizing bundle size for frontend libraries.


Why Unguarded Bundle Size Grows Silently

When you ship a dual-format package — one ESM output consumed by bundlers, one CJS output consumed by Node.js — the ESM path is the critical measurement target. Bundlers that honour the exports field will resolve the import condition and apply tree-shaking to eliminate dead exports. If the ESM output contains non-static patterns or the wrong sideEffects declaration, webpack’s ModuleConcatenation pass will bail out silently and include the entire module graph — inflating every downstream consumer’s bundle with no warning at build time.

Bundlephobia and webpack-bundle-analyzer attack this problem from opposite directions: Bundlephobia measures what the package weighs in isolation on npm; the analyzer shows what ends up inside your own build and, crucially, why specific modules were included.


Bundle measurement pipeline Diagram showing that the library source feeds a webpack dual build producing ESM and CJS dist files; webpack-bundle-analyzer reads stats.json from the build; Bundlephobia CLI fetches the published package from npm; both tools report gzip sizes that feed into a CI size gate step. Library Source webpack dual build dist/esm stats-esm.json dist/cjs stats-cjs.json webpack-bundle -analyzer Bundlephobia CLI CI size gate

Minimal Reproduction: What Triggers Silent Bloat

The following package.json and webpack.config.ts represent the smallest configuration that reveals the problem. The sideEffects: true default and the absence of isolated ESM/CJS stats runs are the two root causes.

Broken package.json (before):

{
  "name": "my-ui-lib",
  "version": "1.0.0",
  "main": "./dist/cjs/index.js",
  "module": "./dist/esm/index.js"
}

This omits the exports field, so webpack falls back to main and cannot tree-shake because CJS modules have dynamic exports. It also lacks a sideEffects declaration, which defaults to true — meaning webpack must assume every file has side-effects and cannot prune unused exports.

Corrected package.json (after):

{
  "name": "my-ui-lib",
  "version": "1.0.0",
  "exports": {
    ".": {
      "import": "./dist/esm/index.js",
      "require": "./dist/cjs/index.js",
      "default": "./dist/cjs/index.js"
    },
    "./package.json": "./package.json"
  },
  "sideEffects": ["**/*.css", "**/*.scss"]
}

Step-by-Step Fix

1. Configure Isolated ESM and CJS Analyzer Runs

Mixing ESM and CJS stats in a single webpack run contaminates the optimizationBailout output — CJS modules always bail, which obscures real bailouts in your ESM path. Use an environment flag to produce separate stats.json files.

// webpack.config.ts
import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer';
import type { Configuration } from 'webpack';

export default (env: { format: 'esm' | 'cjs' }): Configuration => ({
  entry: env.format === 'esm' ? './src/index.esm.ts' : './src/index.cjs.ts',
  output: {
    filename: `index.${env.format}.js`,
    library: {
      type: env.format === 'esm' ? 'module' : 'commonjs2'
    }
  },
  experiments: {
    outputModule: env.format === 'esm'
  },
  plugins: [
    new BundleAnalyzerPlugin({
      analyzerMode: 'json',      // writes stats.json; no browser window in CI
      defaultSizes: 'gzip',      // matches Bundlephobia's reported metric
      reportFilename: `report-${env.format}.json`,
      openAnalyzer: false
    })
  ],
  stats: {
    all: false,
    modules: true,
    reasons: true,              // shows which modules imported each module
    optimizationBailout: true   // surfaces scope-hoisting failures
  }
});

Run both formats:

webpack --config webpack.config.ts --env format=esm --json > stats-esm.json
webpack --config webpack.config.ts --env format=cjs --json > stats-cjs.json

Expected output: Two stats-*.json files and two report-*.json files. Open report-esm.json in webpack-bundle-analyzer’s standalone viewer to get the treemap.

HAZARD PREVENTION: Error: Cannot read properties of undefined (reading 'modules') occurs when default webpack stats omit module data. The stats: { all: false, modules: true } block above is required — without it, BundleAnalyzerPlugin receives an incomplete stats object and throws at serialisation time.


2. Audit optimizationBailout for Tree-Shaking Failures

After building with the config above, scan stats-esm.json for bailout messages. The most common one is:

ModuleConcatenation bailout: Module is not an ECMAScript module

This appears for any module that webpack cannot confirm is pure ESM — typically because it uses require(), module.exports, or a dynamic import() with a non-static specifier.

# List every module path that has a bailout reason
node -e "
const s = require('./stats-esm.json');
s.modules
  .filter(m => m.optimizationBailout && m.optimizationBailout.length)
  .forEach(m => console.log(m.name, m.optimizationBailout));
"

Before (bailout present):

{
  "name": "./src/utils/format.js",
  "optimizationBailout": [
    "ModuleConcatenation bailout: Module is not an ECMAScript module"
  ]
}

After (rename to .mts and use named ESM exports):

{
  "name": "./src/utils/format.mts",
  "optimizationBailout": []
}

Cross-reference any module that has a bailout with your sideEffects array. A file listed as having side-effects in package.json will never be pruned, even if the bailout is fixed — so the sideEffects list should contain only files that genuinely mutate global state (CSS imports, polyfills).


3. Run Bundlephobia CLI with a Hard Size Threshold

Bundlephobia resolves your package in isolation — it does not de-duplicate shared chunks the way a consuming application would — so its number is a conservative worst-case figure for first-time installs.

# Install once as a dev dependency
npm install --save-dev bundlephobia-cli

# Measure ESM output against a 20 kB gzip budget
npx bundlephobia-cli ./dist/esm/index.js \
  --format json \
  --max-size 20kb \
  --gzip \
  --fail-on-exceed

Sample pass output:

{
  "name": "my-ui-lib",
  "size": 14823,
  "gzip": 5102,
  "passed": true
}

Sample fail output (exits code 1):

{
  "name": "my-ui-lib",
  "size": 89234,
  "gzip": 28410,
  "passed": false,
  "error": "Bundle size 27.75 kB exceeds budget of 20 kB"
}

HAZARD PREVENTION: Error: Failed to resolve package exports fires when Bundlephobia’s resolver cannot parse a conditional exports map. Fix: ensure package.json includes an explicit "./package.json": "./package.json" entry. Without it, the resolver may attempt to read sub-path exports and encounter an ambiguous fallback.


4. Wire the Size Check into CI

Add the check to your package.json scripts so it runs automatically before every publish:

{
  "scripts": {
    "build": "tsc -p tsconfig.build.json && webpack --config webpack.config.ts --env format=esm && webpack --config webpack.config.ts --env format=cjs",
    "check-size": "bundlephobia-cli ./dist/esm/index.js --max-size 20kb --fail-on-exceed",
    "prepublishOnly": "npm run build && npm run check-size"
  }
}

In GitHub Actions:

- name: Check bundle size
  run: npm run check-size

The prepublishOnly hook means npm publish will abort before uploading if the ESM output exceeds the budget.


Verification Command

After applying all fixes, run the full verification sequence:

# 1. Clean build
rm -rf dist && npm run build

# 2. Confirm no optimizationBailout in ESM stats
node -e "
const s = require('./stats-esm.json');
const bailouts = s.modules.filter(m => m.optimizationBailout?.length);
if (bailouts.length) { console.error('Bailouts found:', bailouts.length); process.exit(1); }
console.log('No bailouts — ESM output is fully concatenation-eligible.');
"

# 3. Size check
npm run check-size

Expected terminal output:

No bailouts — ESM output is fully concatenation-eligible.
{ name: 'my-ui-lib', gzip: 5102, passed: true }

Edge Cases and Gotchas

  • pnpm vs npm resolution: pnpm’s strict symlink layout means bundlephobia-cli may not resolve hoisted peer dependencies the same way npm does. Run npm pack and point the CLI at the tarball path to get a registry-accurate measurement.
  • CSS-in-JS libraries: Styled-components and Emotion inject style tags at runtime, creating real side-effects. List their import paths in sideEffects — do not set sideEffects: false globally or you will strip the style injection.
  • Vite vs webpack conditionNames: Vite resolves browser before import by default; webpack resolves import before browser. If your exports map has both, the selected file may differ between tools, producing different gzip sizes. Always test with the bundler your consumers actually use.
  • TypeScript moduleResolution: bundler: When you validate types with tsc --noEmit, the bundler resolution mode follows the exports field’s types condition. If types is missing from the exports map, TypeScript falls back to the package root’s types field — which may point to CJS declarations and surface false type errors.
  • Scope hoisting and dynamic imports: A single import(/* webpackChunkName */ './heavy') in your library will split the chunk and prevent concatenation of everything the chunk imports. If you ship lazy loading inside a library, measure each chunk individually, not just index.js.
  • Baseline drift: After the first passing CI run, commit stats-esm.json to version control. On subsequent runs, diff the size fields to detect regressions before they accumulate.

FAQ

Why does Bundlephobia report a different size than webpack-bundle-analyzer?

Bundlephobia bundles the package in isolation against a clean webpack build — it measures what the package weighs on npm, including its own dependencies. webpack-bundle-analyzer measures your actual output bundle, which may benefit from deduplication, scope hoisting, and shared chunks with the rest of your app. The two numbers answer different questions: Bundlephobia is a publishing gate; the analyzer is a debugging tool.

Why do optimizationBailout entries persist after I set sideEffects: false?

Bailouts from module concatenation (scope hoisting) occur for reasons beyond sideEffects. Dynamic imports, re-exported namespaces, and modules referenced from multiple entry chunks all prevent concatenation regardless of the sideEffects flag. Check the exact bailout reason string; Module is referenced from these modules with unsupported syntax requires converting the affected import to a static named import.

Can I use these size checks in a GitHub Actions CI pipeline?

Yes. npx bundlephobia-cli ./dist/esm/index.js --max-size 20kb --fail-on-exceed exits with code 1 on breach so the job fails. For PR-level diff comments showing before/after sizes, consider bundlesize or size-limit which integrate natively with GitHub PR status checks.

What does defaultSizes: 'gzip' in BundleAnalyzerPlugin do?

It instructs the analyzer to display gzip-compressed sizes rather than raw (stat) or parsed (minified) sizes. Gzip sizes reflect actual network transfer bytes and align with what Bundlephobia reports, making it straightforward to cross-reference the two tools.

My package exports both ESM and CJS — which output should I measure?

Measure the ESM output for bundler-facing consumers (webpack, Rollup, esbuild), since those tools use the import condition in the exports field and are the ones performing tree-shaking. Measure CJS separately for Node.js consumers. Conflating the two inflates your reported size and can obscure tree-shaking regressions in the ESM path.



Up: Optimizing Bundle Size for Frontend Libraries