Measuring Bundle Impact with Bundlephobia and Webpack Analyzer
Track npm package size regressions with Bundlephobia and webpack-bundle-analyzer. Set ESM/CJS size baselines, generate visual dependency maps, audit tree-shakeability via optimizationBailout flags, and gate size budgets in CI.
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.
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. Thestats: { all: false, modules: true }block above is required — without it,BundleAnalyzerPluginreceives 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 exportsfires when Bundlephobia’s resolver cannot parse a conditionalexportsmap. Fix: ensurepackage.jsonincludes 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-climay not resolve hoisted peer dependencies the same way npm does. Runnpm packand 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 setsideEffects: falseglobally or you will strip the style injection. - Vite vs webpack
conditionNames: Vite resolvesbrowserbeforeimportby default; webpack resolvesimportbeforebrowser. If yourexportsmap 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 withtsc --noEmit, thebundlerresolution mode follows theexportsfield’stypescondition. Iftypesis missing from the exports map, TypeScript falls back to the package root’stypesfield — 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 justindex.js. - Baseline drift: After the first passing CI run, commit
stats-esm.jsonto version control. On subsequent runs, diff thesizefields 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.
Related
- Implementing the
sideEffectsFlag Correctly — thesideEffectsfield governs which modules webpack can safely drop; misconfiguring it is the most common reasonoptimizationBailoutentries persist. - Eliminating Barrel File Anti-Patterns — barrel re-exports are a leading cause of oversized bundles and are often invisible until you inspect the analyzer treemap.
- Advanced Dead Code Elimination Techniques — goes deeper on webpack’s
usedExportsandprovidedExportsoptions that control what the analyzer marks as unused.