FATAL ERROR: JavaScript Heap Out of Memory in Node.js

Node's 'JavaScript heap out of memory' error explained: two real message shapes, live diagnostics, and fixes beyond raising a number.

v8 memory management garbage collection docker debugging
Bharath_Kumar_G
Reading Progress

On This Page

<--- Last few GCs --->

[555:0x16d1a000]     9402 ms: Mark-Compact 140.3 (157.8) -> 140.3 (158.1) MB, pooled: 0 MB, 38.12 / 0.00 ms (average mu = 0.989, current mu = 0.993)
[555:0x16d1a000]     9432 ms: Mark-Compact 147.9 (165.7) -> 147.9 (181.4) MB, pooled: 0 MB, 25.76 / 0.00 ms (average mu = 0.980, current mu = 0.144) allocation failure; scavenge might not succeed

<--- JS stacktrace --->

FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory

If that's sitting in your terminal right now, jump to "Fixing It Without Just Raising the Number" below. If you want to actually understand what just happened, and make sure it doesn't happen again on the next Node version you upgrade to, keep reading. This one took me a full day and a chunk of an evening, mostly because half of what I thought I knew about V8's default heap size turned out to be five years out of date.

The Two Messages V8 Actually Prints

There are two different sentences V8 can print right before it kills your process, and people paste both of them into search boxes as if they're the same bug. They are, mechanically, but they come from different code paths inside V8's garbage collector, and knowing which one you've got saves you a step.

The one everyone's seen in old blog posts and Stack Overflow answers, straight from Node's own CLI docs:

<--- Last few GCs --->

[49580:0x110000000]     4826 ms: Mark-sweep 130.6 (147.8) -> 130.5 (147.8) MB, 27.4 / 0.0 ms (average mu = 0.126, current mu = 0.034) allocation failure scavenge might not succeed
[49580:0x110000000]     4845 ms: Mark-sweep 130.6 (147.8) -> 130.6 (147.8) MB, 18.8 / 0.0 ms (average mu = 0.088, current mu = 0.031) allocation failure scavenge might not succeed

<--- JS stacktrace --->

FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory

And the one I actually got, every single time, running real reproductions on Node 22.22.2 today.

FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory

"Ineffective mark-compacts" fires when V8 runs several full mark-compact collections back to back and each one frees so little that it gives up: the classic slow leak, dying by inches. "Reached heap limit" fires when a single allocation would blow past the ceiling outright, whether that's one huge object or the tail end of a steady climb. Both come from the same place in V8, V8::FatalProcessOutOfMemory, and both end your process the same way: not a normal exit, not a rejected promise, but Abort(). On Linux that's exit code 134 (128 + signal 6, SIGABRT). Write that number down. It matters later, because it's how you tell this error apart from a container OOM-killer taking your process out with exit code 137, a related but genuinely different failure, and one this article isn't going to cover in depth.

Every stack trace also carries a block of native frames underneath, something like:

1: 0xe42d60 node::OOMErrorHandler(char const*, v8::OOMDetails const&) [node]
2: 0x121ded0 v8::Utils::ReportOOMFailure(...)
3: 0x121e1a7 v8::internal::V8::FatalProcessOutOfMemory(...)
4: 0x144d015  [node]
5: 0x14668a9 v8::internal::Heap::CollectGarbage(...)

Those frames are always native V8/Node internals, never your code. This crash happens one layer below JavaScript, so there's no application stack trace to read. That's the single most confusing thing about this error for people hitting it for the first time: there's no line number pointing at the guilty file.

Break the Heap in Under Twenty Lines

Three shapes, all reproduced live, all CommonJS, run directly with node file.js. No package.json, no build step.

An unbounded array:

javascript

// oom-array.js -- run with: node --max-old-space-size=64 oom-array.js
const arr = [];
while (true) {
  arr.push(new Array(1e6).fill('x'));
}

A growing Map that never evicts anything:

javascript

// oom-map.js -- run with: node --max-old-space-size=48 oom-map.js
const map = new Map();
let i = 0;
while (true) {
  map.set(i, 'x'.repeat(1000));
  i++;
}

A timer-driven leak, closer to what actually happens in a real service: something keeps running on an interval and keeps adding to a shared array that nothing ever clears.

javascript

// oom-interval.js -- run with: node --max-old-space-size=40 oom-interval.js
let store = [];
function leak() {
  for (let i = 0; i < 20000; i++) {
    store.push({ id: i, payload: new Array(50).fill(Math.random()) });
  }
}
setInterval(leak, 5);

All three ran to completion in under fifteen seconds on this machine and all three exited with code 134. --max-old-space-size here is doing you a favor: it's making a slow leak fail fast and loud in a repro, instead of taking twenty minutes to eat 8 GB the way it would with no ceiling set.

Does Node 22, 24, or 26 Change Anything?

Not the wording, and not the fundamental mechanism. This is V8-level, not a Node API, and it hasn't meaningfully changed shape since Node 12. What has changed is how much heap you get by default and how you're allowed to size it, and that history matters more than the message text.

Node 22 (Maintenance LTS, EOL 2027-04-30)Node 24 (Active LTS, EOL 2028-04-30)Node 26 (Current since 2026-05-05)
Error wordingIdenticalIdenticalIdentical
--max-old-space-size-percentageAvailableAvailableAvailable
Cgroup v2 memory detectionCorrectCorrectCorrect
Bundled libuv1.51.01.51.01.52.1

I confirmed the percentage flag exists in the shipped docs for v22.22.0, v24.11.1, and v26.9.0 by pulling doc/api/cli.md straight from the tagged source on each branch. It's not a 26-only feature, it landed as a backport to 22.x too (merged into the v22.x-staging branch, first appearing in a 22.21.0-class release). If you're on an older Node 18 or 20 image, you won't have it, and that alone might be worth the upgrade. Node 27 doesn't have a tagged release yet as of this run. The release schedule I pulled directly from nodejs/Release shows only 22, 24, and 26 as supported lines, with 27 expected to start as Current sometime after Node 26 moves to Active LTS on 2026-10-28.

If you're chasing this on Node 16 or 18, one thing to rule out specifically: cgroup v2 memory-limit detection was actually broken in older libuv (pre-1.45.0, released May 2023), which made Node see the host's memory instead of the container's cgroup limit. That's fixed in every currently supported line. If you're not on one of those, it's not your bug. Still, it's worth knowing the failure mode existed, because plenty of the older write-ups you'll find while researching this were written while it was still broken.

What's Actually Filling the Heap

Underneath both message variants, the mechanical cause is always the same one sentence: your program is holding a reference to more live data than the configured ceiling allows, and garbage collection can't free something that's still reachable. GC doesn't guess intent. If your code can still get to an object, through a closure, an array, a Map, or an event listener's bound this, that object survives, no matter how obviously useless it is to a human reading the code.

I hit a production version of this on a nightly batch job running in a Kubernetes pod capped at 512Mi. It died maybe one night in five, and infuriatingly, not always the same way. Some nights it printed this exact FATAL ERROR block, other nights the pod just vanished and got rescheduled with nothing in the application logs at all. Two different endings for what turned out to be the same root cause: the job read an entire day's transaction export into one array before processing it, and on the nights the file happened to be big enough, sometimes V8's own ceiling caught it first (clean crash, this error), and sometimes the cgroup memory limit caught it first (silent kill, exit 137, no JS-level error at all, because the kernel doesn't ask V8's permission before it acts).

That's the tell worth remembering: if you sometimes see this exact FATAL ERROR and sometimes just see your process disappear with no error at all for what feels like the same underlying leak, you're racing two different limits, V8's heap ceiling versus the OS/cgroup memory limit, and which one wins is mostly about timing and allocation pattern, not about which is "the real" bug.

How V8 Decides How Much Heap You Get

This is the part that sent me down a genuine rabbit hole, because the number I had memorized for "the default heap limit" turned out to be years stale.

Up through Node 11, V8's defaults were hard-coded and browser-shaped: 700 MB on 32-bit builds, 1,400 MB on 64-bit. That's an odd number to hand a server process. It has nothing to do with how much RAM the box actually has. Node 12 (April 2019, via nodejs/node PR #25576) replaced that with V8's own ResourceConstraints::ConfigureDefaults logic: take total physical memory, divide by four, then clamp the result between a 256 MB floor and a 2,048 MB ceiling. On a typical 2019-era 4 GB VM, that gave you roughly a 1 GB old-space default, tighter than the old hard-coded number on small boxes, looser on bigger ones, and for the first time, actually responsive to the machine it was running on.

Three months later, Node 12.7.0 (PR #27508) fixed the container blind spot in that same logic: it started calling libuv's uv_get_constrained_memory() and taking the minimum of that and total physical memory, so a container capped at 512 MB by Docker or Kubernetes would get a sensibly small default instead of one sized off the host's full 64 GB. Worth knowing precisely what that touches: it only affects the number V8 uses to size its own heap ceiling. It does not change what os.totalmem() or os.freemem() report. Those keep reporting host-level numbers inside a container, on purpose, which is a genuine footgun if you've ever written your own cache-sizing logic against os.totalmem() expecting it to reflect your container's actual budget. It doesn't.

Here's where my mental model actually broke: I ran v8.getHeapStatistics() on this sandbox (7.84 GiB of host memory, no cgroup limit set) under Node 22.22.2, expecting to see something near that 2019-era 2,048 MB ceiling.

javascript

// node:v8 and node:os are both core modules, no install needed
const v8 = require('node:v8');
const os = require('node:os');
console.log('os.totalmem (GiB):', (os.totalmem() / 1024 / 1024 / 1024).toFixed(2));
console.log('heap_size_limit (MB):', (v8.getHeapStatistics().heap_size_limit / 1024 / 1024).toFixed(1));
os.totalmem (GiB): 7.84
heap_size_limit (MB): 8241.0

That's not clamped to 2 GB at all. It's tracking close to total system memory. The 2,048 MB ceiling from PR #25576 is real, it landed, and it's still cited in half the blog posts you'll find on this error, but it's clearly not the whole current picture on a machine this size, and I couldn't find a newer PR that documents the exact replacement formula in plain language. I'm not going to hand you a number I can't back up. What I can tell you, confidently, because I ran it: don't trust a remembered constant here. Run v8.getHeapStatistics().heap_size_limit on the actual box or container you care about. It takes one line and it's always current.

The newer --max-old-space-size-percentage flag (confirmed live on Node 22.22.2, and in the docs for 24 and 26) sizes old-space as a percentage of available memory instead of a fixed MB count. That's genuinely useful in a fleet where pod sizes vary, since a Dockerfile that says "70%" scales automatically if someone bumps the memory request later, where a hard-coded --max-old-space-size=1024 silently stops making sense. One nuance I only found by testing it directly: the percentage maps onto old-space specifically, not onto the total heap_size_limit you'd read back from v8.getHeapStatistics() (which also includes young-generation and other spaces). Setting --max-old-space-size-percentage=50 on this 8 GB box produced a heap_size_limit of about 3,040 MB, not 4,016 MB, roughly 38% of the total instead of 50%. Don't expect the number you pass to come back out untouched from a different API; measure what you actually got.

Two Theories I Was Wrong About

First theory: the "Ineffective mark-compacts" message must have been retired. I wrote all three repro scripts above expecting to see it at least once, on the assumption that a slowly-growing Map was exactly the "several ineffective full GCs in a row" pattern that message describes. Every run gave me "Reached heap limit" instead. My first guess was that newer V8 had simply dropped that code path and the docs example was fossilized. Wrong: I checked with strings against the actual Node binary on this box:

bash

strings "$(which node)" | grep -i "mark-compacts near heap limit"
strings "$(which node)" | grep -i "Reached heap limit"

Both strings are compiled into the current binary. The message isn't gone; my repros just weren't triggering the specific "several consecutive degenerate GCs, each barely freeing anything" pattern that produces it. It's a narrower condition than I remembered, not a removed one.

Second theory: the default heap ceiling was still capped near 2 GB. Covered above: wrong, at least on a modern box with more than a few gigabytes of RAM, and I only caught it because I checked v8.getHeapStatistics() instead of trusting what I'd read in older material (including, frankly, my own memory of the 2019 change).

With the theories out of the way, here's the diagnostic path that actually gets you from "it crashed" to "here's what's holding the memory," in the order I'd run it against a real service:

bash

# 1. What ceiling are you actually running under, right now, on this box/container?
node -e "console.log(require('node:v8').getHeapStatistics().heap_size_limit / 1024 / 1024, 'MB')"

# 2. Watch GC pressure build up before the crash, instead of guessing
node --trace-gc your-app.js
# Rising "Mark-Compact" numbers that barely shrink between collections = a real leak,
# not a one-off big allocation.

# 3. Capture heap snapshots automatically as you approach the ceiling
node --max-old-space-size=512 --heapsnapshot-near-heap-limit=3 your-app.js
# Wrote snapshot to Heap.<timestamp>.<pid>.0.001.heapsnapshot (and 002, 003)
# Load two of these into Chrome DevTools' Memory tab, pick "Comparison" view,
# and sort by "# Delta": that's your growing retainer, by constructor name.

# 4. Get the full picture at the moment of the crash
node --report-on-fatalerror --max-old-space-size=512 your-app.js
# Writes report.<timestamp>.<pid>.0.003.json next to your working directory

That last one is worth actually opening. The report I generated this run carries a javascriptHeap.heapSpaces breakdown per V8 space (new_space, old_space, code_space, and friends, each with used/available/capacity), plus a header block with event: "Allocation failed - JavaScript heap out of memory", trigger: "OOMError", the exact commandLine that was running, and the Node version. Everything you'd otherwise have to reconstruct from logs after the fact, captured automatically at the instant of the crash.

Ranked by how often each one is actually the cause, from what I've seen and from this run's research: (1) a genuine memory leak in application code (unbounded caches, Maps or arrays that never evict, listeners that never get removed); (2) one legitimately oversized synchronous operation, like reading an entire large file or a giant JSON response fully into memory instead of streaming it; (3) a heap ceiling that's simply mismatched to the box or container it's running in, usually because someone hard-coded --max-old-space-size for a container size that later changed; (4) the historical cgroup v2 detection bug, now fixed everywhere current, but still live on old base images; (5) the streaming-anti-pattern version of #2, pagination that got skipped, a full-table load where a cursor belonged. The batch-job story above was #2, with #3 making it worse.

Fixing It Without Just Raising the Number

Ranked best-first, and I'd genuinely only reach for the last one as a stopgap:

  1. Fix the actual leak or the oversized load. Bound your caches (an LRU with a real max size, not a plain object that grows forever). Stream large files and large HTTP bodies instead of buffering them whole. Remove listeners you registered with once-shaped intent but attached with on. This is the only fix that removes the failure mode instead of moving its threshold.
  2. Split the work. If a job genuinely needs to hold a lot of live data at once (a big sort, a big aggregation), run it in a worker_thread or a separate process with its own heap ceiling, so a spike in one job can't take down everything sharing the main process's heap.
  3. Size the ceiling to the actual container, deliberately. If the real problem is that the default (or an old hard-coded --max-old-space-size) genuinely doesn't match the box, use --max-old-space-size-percentage set relative to the container's memory request, not the node's physical RAM, and re-check it any time the deployment's memory limit changes, since a hard-coded MB value silently rots the moment infrastructure changes underneath it.
  4. Raise the ceiling as a stopgap only, with a ticket to come back and do #1. NODE_OPTIONS=--max-old-space-size=2048 node app.js (or the equivalent in your process manager's env config) buys you time. It doesn't buy you correctness.

For the batch job, the actual fix was #1: read the export as an NDJSON stream and process records one at a time instead of collecting them into an array first. Memory use went from "however big today's file is" to a flat, predictable few megabytes, and the flaky 512Mi pod stopped flaking.

Code That Can't Quietly Balloon

The unbounded accumulator is the shape that keeps causing this, so make that shape impossible to write by accident. Before:

javascript

// Grows forever: nothing ever leaves this array
const recentEvents = [];
function onEvent(evt) {
  recentEvents.push(evt);
}

After: same behavior for the caller, but with a hard ceiling:

javascript

const MAX_RECENT = 5000;
const recentEvents = [];
function onEvent(evt) {
  recentEvents.push(evt);
  if (recentEvents.length > MAX_RECENT) {
    recentEvents.splice(0, recentEvents.length - MAX_RECENT);
  }
}

And for the "read the whole file first" version of this bug, prefer a stream over readFileSync or fs.readFile whenever the input size isn't bounded by something you control:

javascript

const { createReadStream } = require('node:fs');
const readline = require('node:readline');

async function processLargeFile(path, handleLine) {
  const rl = readline.createInterface({ input: createReadStream(path) });
  for await (const line of rl) {
    handleLine(line);
  }
}

Neither of these is exotic. That's the point: the fix for this class of bug is almost always boring, bounded, structural code, not a cleverer garbage collector setting.

Catching This Before the Pager Goes Off

  • Wire --report-on-fatalerror into every production process, permanently, not just while you're debugging. It's free until it fires, and when it does, you get the full state instead of a bare stack of native frames.
  • Track heapUsed / heap_size_limit as a ratio in whatever APM or metrics stack you already run, and alert on a sustained upward trend, not just a threshold crossing. A slow leak looks like a staircase over hours, not a spike.
  • In containers, set --max-old-space-size-percentage against the pod's memory request rather than a static MB figure, so the ceiling scales automatically the next time someone resizes the deployment.
  • Add a load test that runs long enough (not just a quick burst) to surface a leak that only shows up after hundreds of thousands of requests. A five-second smoke test will never catch this.
  • Reach for a real leak-hunting tool (clinic heapprofiler, or comparing two --heapsnapshot-near-heap-limit snapshots in DevTools) the first time this happens in staging, rather than guessing from process.memoryUsage() alone.

This error is a close neighbor of two topics worth knowing exist even though they're not the same bug: a container getting OOM-killed with exit code 137 and no JavaScript error at all (a cgroup-level kill, not a V8-level one), and MaxListenersExceededWarning (a narrower, EventEmitter-specific flavor of "something isn't getting cleaned up"). If what you're actually staring at is a silent exit with no FATAL ERROR block, you're probably in the first one, not this one.

What This Buys You Going Forward

Know your exit codes: 134 is V8 giving up cleanly, 137 is the OS taking the process out from underneath it, and they point you in different directions. Query v8.getHeapStatistics() on the actual machine instead of trusting a remembered default. The numbers move between Node versions more than the docs make obvious. Treat --max-old-space-size (fixed or percentage) as a way to fail fast in development and as a deliberate, container-aware setting in production, never as the fix for a leak you haven't found yet. And when this does fire in production, --report-on-fatalerror and a couple of --heapsnapshot-near-heap-limit snapshots will tell you more in five minutes than an hour of re-reading your own code will.

v8memory managementgarbage collectiondockerdebugging

Engineer and writer behind CODELZ. I read the stack trace, reproduce the bug, and explain why it happens, not just how to silence it. Plus honest reviews of the books and tools worth your time.

Comments