A Node process that exhausts its heap does not throw — it aborts:
<--- Last few GCs --->
[17682:0xb7d40c000] 233 ms: Mark-Compact 72.4 (74.5) -> 72.4 (74.5) MB, pooled: 0.0 MB, 75.97 / 0.00 ms (average mu = 0.062, current mu = 0.000) allocation failure; GC in old space requested
[17682:0xb7d40c000] 258 ms: Mark-Compact (reduce) 72.4 (74.5) -> 71.9 (74.5) MB, pooled: 0.0 MB, 25.26 / 0.00 ms (average mu = 0.042, current mu = 0.000) last resort; GC in old space requested
FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory
----- Native stack trace -----
Read the GC lines before reaching for a flag — they contain the diagnosis. 72.4 -> 72.4 MB means a full mark-compact collection freed nothing. That is what "ineffective" means: V8 ran the collector, and the memory is still reachable, so it is not garbage. The process then aborts with exit code 134 (SIGABRT).
If you legitimately need more memory, raise the limit:
node --max-old-space-size=4096 app.js
But decide first whether you are hitting a real ceiling or leaking. The GC lines tell you which.
First: Which Failure Is This?
Two different failures get reported as "out of memory", and they need opposite responses.
| Symptom | Exit code | What happened | Fix |
|---|---|---|---|
FATAL ERROR: ... JavaScript heap out of memory with GC lines | 134 | V8 hit its own heap limit | Raise --max-old-space-size, or fix the leak |
| Killed silently, no Node output at all | 137 | The OS or container OOM killer | Raise the container memory limit |
Exit 137 with no message is not a V8 problem — something outside Node killed the process before V8 noticed. In Docker or Kubernetes that means the container memory limit, and raising --max-old-space-size makes it worse by encouraging Node to allocate more before dying. Check the container limit first.
Find your actual heap ceiling:
node -p "require('v8').getHeapStatistics().heap_size_limit / 1024 / 1024"
# 4144.0
Modern Node sizes old-space from available system memory rather than a fixed default, which is exactly why the same code survives on a 32 GB laptop and dies in a 512 MB CI container.
Cause 1: The Workload Genuinely Needs More Memory
Large builds, big data transforms and heavy test suites can legitimately exceed the default. Raising the limit is the right answer here.
node --max-old-space-size=4096 app.js
The value is in megabytes. To reach npm scripts and any child processes they spawn, set it through the environment instead of on one command:
export NODE_OPTIONS=--max-old-space-size=4096
npm run build
Or bake it into the script so everyone gets it:
{
"scripts": {
"build": "cross-env NODE_OPTIONS=--max-old-space-size=4096 webpack --mode production"
}
}
cross-env matters — the inline VAR=value command form is shell syntax that PowerShell and cmd reject.
Pick the number against real memory, not aspiration. Node also uses memory outside the JS heap — Buffers, native modules, the code cache — so the process is always larger than --max-old-space-size. Leave 25% or so of the machine free.
Cause 2: A Memory Leak
The tell is the shape of the failure: usage climbs steadily and the crash arrives after a predictable interval, and doubling the heap roughly doubles the time to crash rather than preventing it.
Watch it grow:
setInterval(() => {
const { heapUsed, heapTotal } = process.memoryUsage();
console.log(`heap ${(heapUsed / 1e6).toFixed(1)} / ${(heapTotal / 1e6).toFixed(1)} MB`);
}, 5000);
A sawtooth that returns to a stable baseline is healthy. A staircase that never comes back down is a leak.
The recurring culprits in Node:
- Listeners added per request and never removed. Node's own warning —
MaxListenersExceededWarning— usually appears first and is worth treating as an error. - An unbounded cache or
Mapkeyed by user, request or session, with no eviction. - Timers that outlive their owner. A
setIntervalholds its closure, and its closure holds everything it references, forever. - Accumulating into a module-level array — request logs, metrics, buffered results.
- Closures over large objects captured by a long-lived callback.
Capture evidence rather than guessing. Node can write a snapshot automatically just before the abort:
node --heapsnapshot-near-heap-limit=1 --max-old-space-size=2048 app.js
Or take them on demand and compare:
const v8 = require('v8');
setInterval(() => v8.writeHeapSnapshot(), 60_000);
Open the .heapsnapshot files in Chrome DevTools → Memory → Load, then use the Comparison view between two snapshots and sort by retained size. The constructor that grew between them is your leak, and the retainer path shows what is holding it.
Cause 3: Build Tooling
Builds hit this far more often than runtime code, because a bundler holds the entire module graph, the ASTs and the source maps in memory simultaneously.
Confirm the cause cheaply by turning off source maps — if the build then passes, they were the bulk of it:
// webpack.config.js
module.exports = { devtool: false };
Then reduce the peak rather than only raising the ceiling:
# TypeScript: skip type-checking library .d.ts files
tsc --skipLibCheck
# Jest: cap parallel workers, each of which is its own heap
jest --maxWorkers=2
# Next.js and similar: build with a raised limit via NODE_OPTIONS
NODE_OPTIONS=--max-old-space-size=8192 next build
Jest is worth calling out: each worker is a separate Node process with its own heap, so --max-old-space-size applies per worker. Raising both the limit and the worker count multiplies total memory and is a common way to make CI worse.
Cause 4: Loading Everything at Once
Code that reads an entire file or result set into memory scales with input size and will eventually fail on a large enough input regardless of the heap limit.
// Fails once the file is bigger than the heap
const data = fs.readFileSync('huge.csv', 'utf8');
data.split('\n').forEach(process);
// Constant memory regardless of file size
const rl = readline.createInterface({ input: fs.createReadStream('huge.csv') });
for await (const line of rl) process(line);
The same principle applies to databases — page through results with LIMIT/OFFSET or a cursor instead of materialising the whole table — and to HTTP responses, where piping a stream to the client avoids buffering the payload.
Verify the Fix
For a raised limit, confirm the process actually received it:
node -p "require('v8').getHeapStatistics().heap_size_limit / 1024 / 1024"
For a suspected leak, the real test is that memory stabilises under sustained load rather than that the crash took longer:
node --expose-gc -e "
setInterval(() => { global.gc(); console.log((process.memoryUsage().heapUsed/1e6).toFixed(1)+' MB'); }, 5000);
require('./app');
"
Forcing a collection before each reading strips out normal GC sawtooth. If the post-GC number keeps climbing, memory is genuinely retained and the leak is still there.
Prevention
- Set an explicit
--max-old-space-sizein containers, comfortably below the container memory limit, so V8 aborts with a readable message instead of being SIGKILLed silently. - Stream large inputs. Anything that scales with input size should not be held whole in memory.
- Bound every cache. An LRU with a maximum size is barely more code than a
Mapand cannot grow without limit. - Treat
MaxListenersExceededWarningas a bug, not noise — it is usually the earliest signal of a leak. - Monitor
heapUsedin production and alert on a rising floor rather than on peaks. The floor is what distinguishes a leak from normal load.