Your bundle-analyzer numbers are 3x too big and CI hangs on it
This page fixes one specific failure: webpack-bundle-analyzer reports sizes that bear no relation to what users download, and the plugin stalls your pipeline. It is a focused companion to the broader Webpack bundle analysis techniques workflow and the JavaScript Bundle Optimization & Code Splitting overview — read those for the full diagnostic loop; read this when the analyzer itself is the thing misbehaving. The symptom set is consistent: a treemap that says your entry chunk is 600KB when production transfer is closer to 130KB Brotli, a CI job that times out after ten minutes, and budget assertions that fail on phantom bytes from source maps.
Rapid Diagnosis: A 60-Second DevTools Checklist
Before touching config, confirm which of the three failures you actually have. Run through this quickly:
- Open the analyzer report and compare its "parsed" total against the Network tab's transferred size for the same chunk under production. A 3–5x gap means you measured a development build.
- Check whether the CI job that produces the report ever exits, or whether it sits at 100% with no further log lines. A stall is
analyzerMode: 'server'waiting for a browser that does not exist. - In your budget script, log the asset list it reads from
stats.json. If.mapfiles appear in the size sum, your thresholds are failing on bytes users never download. - In the Coverage panel, reload and compare unused bytes against the analyzer's module tree. A large discrepancy points to dynamically imported chunks the static report missed.
Root Cause Analysis
Failure mode 1: measuring an unminified development build
Development builds skip minification, mangling, and scope hoisting. Raw identifiers and whitespace inflate the bundle 3–5x, and crucially, dead code elimination has not run — unused exports and dead branches are still present. Every optimisation you derive from these numbers targets a bottleneck that does not exist in production. The mechanism is simple: the analyzer reports exactly the bytes the compiler emitted, and a dev build emits debug-friendly, uncompressed output by design.
Failure mode 2: server mode blocking a headless runner
The plugin's default analyzerMode: 'server' launches an interactive HTTP server and tries to open a browser. CI runners have no display server, so the process blocks indefinitely waiting for a window that can never appear. The build never reports a failure — it simply hangs until the job times out, which masks the real cause behind a generic timeout error.
Failure mode 3: source maps polluting size math
When stats.assets is summed without filtering, .map files are counted as payload. A hidden source map can be larger than the chunk it describes, so budget assertions fail on bytes the browser never requests. The fix is to emit maps that are not referenced from the bundle and to filter the asset list to .js before any threshold check.
Failure mode 4: the analyzer runtime leaking into the shipped bundle
A plugin pushed unconditionally into the plugins array can, in misconfigured setups, add analysis overhead to the output and expose your internal module graph to the browser. Conditional injection behind an environment flag guarantees the plugin runs only during the analysis compilation and never on a deploy build.
Step-by-Step Resolution
Fix 1: gate the plugin behind an environment flag
Highest impact, because it eliminates failure modes 1 and 4 at once. Run analysis only when explicitly requested, against a production build.
ANALYZE=true npm run build:prod
// webpack.config.js
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');
module.exports = (env) => {
const plugins = [/* existing plugins */];
if (env && env.ANALYZE) {
plugins.push(new BundleAnalyzerPlugin({
analyzerMode: 'static', // trade-off: 'static' is correct for any automated run;
openAnalyzer: false, // switch to 'server' only for hands-on local exploration.
generateStatsFile: true,
statsFilename: 'stats.json',
reportFilename: 'reports/bundle-report.html'
}));
}
return { mode: 'production', plugins /* rest of config */ };
};
Expected outcome: reported sizes drop to true production values (commonly 3–5x smaller), and no analyzer code ever lands in a shipped bundle.
Fix 2: force static mode and disable the browser in CI
This removes failure mode 2. With analyzerMode: 'static' and openAnalyzer: false, the build writes a self-contained HTML file plus stats.json and exits cleanly.
- Set
openAnalyzer: falsein every CI configuration. - Confirm the build exits with code
0after the report is written. - Verify no
localhostport is bound during the build step.
Expected outcome: turns an indefinite hang into a clean run that adds only the analysis compilation time, typically a few seconds.
Fix 3: emit hidden source maps and filter the asset list
Resolves failure mode 3. Generate maps that are not referenced from the bundle, then count only .js assets when checking budgets.
// webpack.config.js
module.exports = {
// trade-off: 'hidden-source-map' keeps maps for error reporting without a sourceMappingURL
// comment; if you do not upload maps to an error tracker, use false and skip them entirely.
devtool: 'hidden-source-map'
};
Expected outcome: budget assertions stop counting map bytes, removing false failures that were inflating totals by the full size of each .map.
Fix 4: enforce a gzip threshold in a post-build script
The payoff step. Parse stats.json, measure the main chunk against a compressed limit, and fail the build on violation so regressions never merge.
// scripts/check-bundle-size.mjs
import { readFileSync } from 'fs';
import { gzipSizeSync } from 'gzip-size';
const stats = JSON.parse(readFileSync('dist/stats.json', 'utf8'));
// trade-off: filtering to /main.*\.js$/ suits a single-entry app; for multi-entry builds,
// loop stats.entrypoints so a regressed secondary entry cannot slip through unmeasured.
const mainAsset = stats.assets.find(a => /main.*\.js$/.test(a.name));
if (!mainAsset) {
console.error('FAIL: Could not find main chunk in stats.json');
process.exit(1);
}
const gzipped = gzipSizeSync(readFileSync(`dist/${mainAsset.name}`, 'utf8'));
const limitBytes = 153_600; // 150KB
if (gzipped > limitBytes) {
console.error(`FAIL: Main chunk is ${Math.round(gzipped / 1024)}KB gzipped (limit: 150KB)`);
process.exit(1);
}
console.log(`PASS: Main chunk is ${Math.round(gzipped / 1024)}KB gzipped.`);
Note: gzip-size v7+ ships gzipSizeSync as a named export from an ESM-only package — use import as shown, or pin v6 for CommonJS. Expected outcome: a hard PR gate that blocks any change pushing the entry chunk past 150KB gzipped.
Verification
Confirm the fix held with three independent checks. First, the before/after delta: a build that previously reported, say, a 612KB entry now reports ~128KB, matching the Network tab's transferred column for the same chunk in production. Second, the CI signal — the job that used to time out now completes and the budget step prints PASS with a real compressed number. Third, a field cross-check: after deploying a build that passed the gate, confirm your RUM data shows no regression in Interaction to Next Paint at p75, since smaller transfer should hold or improve main-thread readiness.
For deeper interpretation of the report once the numbers are trustworthy — reading the treemap, spotting vendor overlap, and splitting chunks — work through Webpack bundle analysis techniques. If the analyzer still shows dead modules surviving into production, the cause is usually a static-analysis blocker covered in tree shaking and dead code elimination.
Related
- Webpack bundle analysis techniques is the parent workflow for reading and budgeting the report this page makes accurate.
- JavaScript Bundle Optimization & Code Splitting sets the overall payload-reduction strategy these numbers feed.
- Tree shaking and dead code elimination explains why dead modules can survive even a correct production build.