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.

Where 3.2 GB of peak build memory goes For a five thousand module project, module sources and syntax trees account for one thousand one hundred megabytes, source maps for nine hundred and forty, the type checker for six hundred and eighty, minification for three hundred and twenty, and the bundle analyser for one hundred and sixty. Peak memory of one production build, 5 000 modules sources + syntax trees 1 100 MB · irreducible source maps 940 MB · largest single win type checker 680 MB · move to its own process minification 320 MB bundle analyser 160 MB · development only

Step-by-Step Fix

  1. 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.
  2. Measure per phase. Command: the plugin hook below, logging heapUsed at each compilation stage. Expected: one phase dominates; that is the one to attack.
  3. Turn off the optional artefacts. Verification: rebuild with source maps disabled and confirm how much peak falls — often 25–35%.
  4. 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.
  5. 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"
  }
}
Heap across the phases of one build Heap rises through resolution and parsing, peaks at three point two gigabytes during transformation and source-map generation, falls during emit, and ends near one gigabyte. The default ceiling of two gigabytes cuts across the peak, which is where the build aborts. The peak is narrow — and it is the only part that matters 0 2 GB 4 GB default ceiling ~2 GB — build aborts here resolve parse transform + maps minify emit peak 3.2 GB Cheaper source maps lower the middle of this curve; nothing lowers the resolve and parse phases much.

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.

Five settings, ranked by memory saved Cheaper source maps save about nine hundred megabytes at the cost of debugging fidelity in development. Moving type checking to its own process saves six hundred and eighty. Parallel minification saves two hundred and forty. Disabling the analyser saves one hundred and sixty. Splitting chunks saves around two hundred with a small build-time cost. Work down this list before touching the ceiling Change Peak saved Trade-off Cheaper source maps in development ~900 MB column-level fidelity in dev only Type check in a separate process ~680 MB two ceilings to manage Parallel minification in forks ~240 MB more CPU, more total RSS Analyser off by default ~160 MB run it on demand instead Together these turn a 3.2 GB peak into roughly 1.2 GB, which fits the default ceiling with room to grow.

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.