Skip to main content
DevOpsintermediate

Fix "JavaScript heap out of memory" in Node.js

Fix `FATAL ERROR: Allocation failed - JavaScript heap out of memory`. Raise the heap limit, or find the leak that is really causing it.

10 min readUpdated August 2026

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.

SymptomExit codeWhat happenedFix
FATAL ERROR: ... JavaScript heap out of memory with GC lines134V8 hit its own heap limitRaise --max-old-space-size, or fix the leak
Killed silently, no Node output at all137The OS or container OOM killerRaise 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.

Advertisement

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 Map keyed by user, request or session, with no eviction.
  • Timers that outlive their owner. A setInterval holds 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-size in 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 Map and cannot grow without limit.
  • Treat MaxListenersExceededWarning as a bug, not noise — it is usually the earliest signal of a leak.
  • Monitor heapUsed in production and alert on a rising floor rather than on peaks. The floor is what distinguishes a leak from normal load.

Frequently Asked Questions

Find answers to common questions

V8 hit its maximum old-space size and garbage collection could not free enough to continue, so Node aborted. The 'Ineffective mark-compacts' line means the collector ran repeatedly and reclaimed almost nothing, which is V8's signal that the memory is genuinely still referenced rather than garbage.

Pass --max-old-space-size with a value in megabytes, either directly as 'node --max-old-space-size=4096 app.js' or through the environment as NODE_OPTIONS=--max-old-space-size=4096 so it reaches npm scripts and child processes too.

Set it below the memory actually available, leaving headroom for the OS and for Node's non-heap memory such as Buffers and native modules. On an 8 GB machine 4096 is reasonable; on a 2 GB container 1536 is closer to the ceiling. Setting it above physical memory just swaps the crash for thrashing or an OS kill.

Modern Node sizes old-space from available system memory rather than using one fixed value, which is why the same code can survive on a laptop and crash in a small container. Check the actual limit with: node -p "v8.getHeapStatistics().heap_size_limit / 1024 / 1024".

No. It postpones the crash. If usage grows without bound, a bigger heap means a longer run before the same failure, usually with worse pauses as garbage collection works over more memory. Raising the limit is correct for genuinely large workloads and wrong for leaks.

Bundlers, TypeScript and test runners hold whole dependency graphs and source maps in memory at once, so peak usage during a build can dwarf runtime usage. Source maps in particular are memory-hungry — building without them is often the quickest way to confirm the cause.

134 is SIGABRT: V8 detected the heap limit and aborted itself, printing the FATAL ERROR message. 137 is SIGKILL, usually the OS or container out-of-memory killer stepping in first, which produces no Node output at all. A silent 137 in Docker or Kubernetes normally means the container memory limit, not the V8 heap limit.

Take a heap snapshot and compare two points in time. Run with --heapsnapshot-near-heap-limit=1 to capture one automatically just before the crash, or call v8.writeHeapSnapshot() at intervals, then open the .heapsnapshot files in Chrome DevTools and sort by retained size.

Containers usually have far less memory than a laptop, and Node sizes its heap from what it sees. Set an explicit --max-old-space-size that fits inside the container's memory limit, and keep it comfortably below that limit so the OS killer does not fire first.

No. It is a fatal V8 abort, not a JavaScript exception — try/catch and process error handlers never run. The only way to survive it is to not reach the limit: process data in streams or batches, or split the work across processes.