JavaScript Heap Out of Memory During Webpack Builds
FATAL ERROR: Reached heap limit — Allocation failed during a build is the same V8 error a server produces, arriving for an entirely different reason: a bundler legitimately holds the whole project in memory at once. This page belongs to memory limits and out-of-heap errors in Node.js inside JavaScript Memory Fundamentals & Runtime Mechanics.
| Symptom | Root Cause | Immediate Action |
|---|---|---|
| Build dies at the same module every time | Peak arrives during transformation of a large graph | Measure per-phase peak before changing anything |
| Only the type-check step dies | A forked checker with its own, lower ceiling | Raise that process’s ceiling, not the bundler’s |
| Peak doubled after enabling source maps | Full maps retain sources and mapping tables | Use a cheaper map variant, or maps on release only |
| Build fine locally, dies in CI | The runner has less memory than the laptop | Set the ceiling from the runner’s actual limit |
| Watch mode grows over hours | Stale compilations retained between rebuilds | Restart the watcher, or fix the retaining plugin |
Root Cause: The Whole Graph, All At Once
A bundler is the opposite of a streaming workload. To resolve imports, tree-shake, and produce correct output, it needs the entire module graph resident: every source file as text, its parsed syntax tree, the transformed output of each loader stage, and the mapping tables that connect them back to the original sources.
The multiplier is what surprises people. A 200 KB source file becomes a syntax tree several times larger, then a transformed tree, then generated code, then a source map. Multiply by five thousand modules and a project whose src directory is 40 MB on disk can peak past 3 GB in memory. None of that is a leak — it is the working set, and it is why build tooling routinely needs a ceiling several times larger than the service it produces.
On top of that sit the optional costs. Full source maps are frequently the largest single contributor. A bundle analyser holds a second copy of the statistics for every module. A type checker running in-process holds the whole program’s type graph alongside the bundler’s module graph. Each is individually reasonable and collectively fatal.
Watch mode adds the one genuine leak in this category: a plugin that retains references to previous compilations keeps every rebuild’s module graph alive, so peak memory grows over a day of development even though each individual build is the same size.
Step-by-Step Fix
- Identify the dying process. Expected: the stack trace names the bundler, the type checker or the minifier. Each has its own ceiling and its own fix.
- Measure per phase. Command: the plugin hook below, logging
heapUsedat each compilation stage. Expected: one phase dominates; that is the one to attack. - Turn off the optional artefacts. Verification: rebuild with source maps disabled and confirm how much peak falls — often 25–35%.
- Move type checking out. Verification: the bundler process’s peak falls by the checker’s whole footprint; the checker’s own ceiling is now set independently.
- Raise the ceiling last, deliberately. Command:
NODE_OPTIONS=--max-old-space-size=6144 npm run build. Verification: the build completes with headroom, and the value is recorded next to the reason.
Command & Code Reference
Measuring instead of guessing — a small plugin that logs peak heap per phase.
// webpack.config.js — a diagnostic plugin, not something to ship enabled.
class MemoryProbe {
apply(compiler) {
const mb = () => (process.memoryUsage().heapUsed / 1048576).toFixed(0);
const log = (phase) => console.log(`[mem] ${phase.padEnd(18)} ${mb()} MB`);
compiler.hooks.compile.tap('MemoryProbe', () => log('compile start'));
compiler.hooks.make.tapAsync('MemoryProbe', (_c, cb) => { log('make'); cb(); });
compiler.hooks.afterCompile.tapAsync('MemoryProbe', (_c, cb) => {
log('after compile'); cb();
});
compiler.hooks.emit.tapAsync('MemoryProbe', (_c, cb) => { log('emit'); cb(); });
compiler.hooks.done.tap('MemoryProbe', () => log('done'));
}
}
module.exports = { plugins: [new MemoryProbe()] };
The two configuration changes that move peak memory most.
module.exports = {
// Full source maps retain original sources plus mapping tables for every
// module. A cheaper variant keeps line-level fidelity at a fraction of the
// memory; reserve the full map for release builds that need it.
devtool: process.env.RELEASE ? 'source-map' : 'eval-cheap-module-source-map',
optimization: {
// Minifying in parallel forks moves that memory out of the main process,
// where the ceiling is already under pressure.
minimize: true,
minimizer: [new TerserPlugin({ parallel: true })],
},
// Splitting the graph reduces the size of any single chunk's working set.
optimization: {
splitChunks: { chunks: 'all', maxSize: 250_000 },
},
};
Setting build-time ceilings per process, which is where the type checker usually needs its own number.
{
"scripts": {
"build": "cross-env NODE_OPTIONS=--max-old-space-size=6144 webpack --mode production",
"typecheck": "cross-env NODE_OPTIONS=--max-old-space-size=4096 tsc --noEmit",
"ci": "npm run typecheck && npm run build"
}
}
Verification & Regression Prevention
Verify by measuring peak, not by observing that the build completed. A build that finishes 40 MB under its ceiling will fail on the next feature branch, and the only warning is the number. Log peak heap at the end of every CI build and keep it as a time series next to bundle size.
For prevention, treat build memory as a budget the same way bundle size is. A threshold that fails the build when peak exceeds, say, 80% of the ceiling gives a warning while there is still room to act — and it surfaces the architectural signal, which is that the module graph is outgrowing a single-process build and wants splitting into independently buildable packages.
FAQ
Why do builds need so much more memory than the app?
A bundler holds the entire module graph in memory at once: every source file, its abstract syntax tree, its transformed output and its source map, plus the dependency edges between them. A 5 000 module project can hold several gigabytes at peak even though the shipped bundle is a few megabytes.
Do source maps really cost that much?
They are frequently the single largest contributor. A full source map retains original sources plus mapping tables for every module, often doubling peak memory. Switching from a full map to a cheaper variant, or generating maps only for release builds, is usually the fastest single win.
Is raising the heap ceiling for CI acceptable?
For a build process, yes — it is a short-lived batch job on a machine you control, not a long-lived service. The caution is different here: a build that needs an ever-larger ceiling is telling you the module graph is growing faster than the architecture is being split.
Related
- Memory Limits and Out-of-Heap Errors in Node.js — what the abort means and how V8 gets there
- Setting max-old-space-size Correctly — choosing the value once you have measured
- Why Does My Node.js Process Hit the Heap Limit — separating a large working set from a leak