Ch. 6 · Node.js

Node.js 'JavaScript heap out of memory': Reached Heap Limit Fix

Fix Node.js 'Reached heap limit Allocation failed - JavaScript heap out of memory': tell leaks from big data, take heap snapshots, stream.

~8 min readintermediateupdated Oct 4, 2026

The process dies with output like this (Node v22.23.2 on macOS, run with a deliberately small heap; GC lines shortened):

<--- Last few GCs --->

[6374:0xc6980c000]       99 ms: Mark-Compact 35.9 (56.3) -> 31.3 (66.2) MB, pooled: 0 MB, 26.08 / 0.00 ms ...

<--- JS stacktrace --->

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

 1: 0x1048bb750 node::OOMErrorHandler(char const*, v8::OOMDetails const&) [.../bin/node]
 2: 0x104a97464 v8::internal::V8::FatalProcessOutOfMemory(v8::internal::Isolate*, char const*, v8::OOMDetails const&) [.../bin/node]
 ...
Text

V8, the JavaScript engine inside Node, needed more memory for JavaScript objects than its configured heap limit allows, ran full garbage collections, and still could not make room. It aborts the whole process (exit code 134 on macOS and Linux, from SIGABRT). No try/catch or process.on('uncaughtException') can intercept it. The exact text after FATAL ERROR: varies slightly between versions (older releases also print Ineffective mark-compacts near heap limit), but JavaScript heap out of memory is stable.

Quick fix checklist

  • Check the actual limit: node -p "v8.getHeapStatistics().heap_size_limit / 1024 / 1024" (in MB).
  • If it is a build tool (webpack, tsc, Angular, Next) on a big project, raising the limit for that one command is reasonable: NODE_OPTIONS=--max-old-space-size=4096 npm run build.
  • If it is a long-running server that dies after hours or days, assume a leak and capture heap snapshots before raising anything.
  • If it is a job that loads a whole file, query result or API response, process it as a stream or in pages.
  • In containers, keep --max-old-space-size well below the memory limit; above it, the kernel kills the process instead.

Before you start

You need Node 22 or 24, Chrome or another Chromium browser (for DevTools), and enough disk space for heap snapshots, which are larger than the heap they describe. Know the difference between heapUsed (live JavaScript objects) and rss (everything the process holds, including Buffers and native memory). This error is about the JavaScript heap specifically.

Why it happens

V8 splits the heap into a small young generation, where new objects are born and most die quickly, and an old generation for objects that survive. --max-old-space-size caps the old generation. When an allocation would push past the cap, V8 runs a full mark-compact collection. The Last few GCs block shows those attempts: 35.9 -> 31.3 MB means a collection freed only about 4.6 MB because almost everything was still reachable. When repeated collections cannot get under the limit, V8 calls its fatal out-of-memory handler.

The default limit is chosen at startup from the memory Node detects. On an 8 GB Mac running Node 22.23.2 it is 2096 MB. In containers the detected value depends on the Node version and how the container exposes its limit, so measure it inside the container rather than assuming.

There are three different situations behind the same message:

  1. A leak. Something keeps references to objects that are no longer needed: an unbounded Map cache, listeners added per request and never removed, a closure holding a large buffer, an array of “recent” events that is never trimmed. Heap usage after each GC climbs steadily and the crash time depends on traffic.
  2. A big working set. The code is correct but holds too much at once: fs.readFileSync on a 2 GB log, JSON.parse of a huge response, SELECT * into an array. It fails at the same point every time, for the same input.
  3. A tool that legitimately needs more. Type checkers and bundlers keep whole programs in memory. A large monorepo can exceed the default without anything being wrong.

Large Buffers live outside the JavaScript heap, so a process can be killed for using too much total memory without ever printing this message. In a container, that shows up as OOMKilled and exit code 137 instead.

Step-by-step walkthrough

Step 1: Reproduce with a small heap

You do not need to wait hours or use gigabytes. Shrinking the limit makes the failure fast and safe to investigate:

// leak.js: a cache keyed per request that is never evicted
const cache = new Map();
let i = 0;
setInterval(() => {
  for (let j = 0; j < 2000; j++) {
    cache.set(`req-${i++}`, { body: 'x'.repeat(200) + i, at: Date.now() });
  }
}, 1);
JavaScript
node --max-old-space-size=32 leak.js
echo $?
Terminal

This prints the fatal error within a fraction of a second and exits with 134.

Step 2: Measure the trend

Before reaching for snapshots, log heap usage over time. A healthy server’s heapUsed saws up and down around a stable level; a leaking one rises at the bottom of each cycle.

const mb = (n) => Math.round(n / 1024 / 1024);
setInterval(() => {
  const { heapUsed, heapTotal, rss, external } = process.memoryUsage();
  console.log(JSON.stringify({ heapUsed: mb(heapUsed), heapTotal: mb(heapTotal), rss: mb(rss), external: mb(external) }));
}, 30_000).unref();
JavaScript

If heapUsed stays flat but rss or external grows, the problem is Buffers or native memory, not this error’s path, and raising the heap limit will not help at all.

Step 3: Capture heap snapshots

Ask Node to write a snapshot automatically just before it would crash:

node --max-old-space-size=64 --heapsnapshot-near-heap-limit=1 leak.js
Terminal
Wrote snapshot to /home/dev/jobs/Heap.20261004.181611.6404.0.001.heapsnapshot
Text

V8 temporarily raises the limit while it writes the file, then the process still crashes. In the test above, a 64 MB heap produced a 150 MB snapshot file, so plan for disk space and for the extra memory: the snapshot process needs memory roughly comparable to the heap being written. For a server you can also attach a debugger (node --inspect app.js, then open chrome://inspect) and take snapshots manually from the Memory tab, or call v8.writeHeapSnapshot() from an admin-only endpoint. Writing a snapshot blocks the event loop, so do this on a canary or a drained instance, not your busiest pod.

Step 4: Read the snapshot in Chrome DevTools

Open DevTools on any page, go to Memory, and load the .heapsnapshot file. Useful views:

  • Summary, sorted by Retained Size: which constructors hold the most memory. In the leak example, a Map with hundreds of thousands of entries dominates.
  • Retainers panel for a selected object: the path of references that keeps it alive, ending at something global (a module-level variable, a timer, an event emitter). That path is your bug.
  • Comparison between two snapshots taken minutes apart: the objects whose count only grows.

Step 5: Fix the cause, then size the limit

For leaks, bound or remove the retaining structure: an LRU with a maximum size, WeakMap keyed by objects whose lifetime you do not own, emitter.off() when a request ends. For big working sets, stop holding everything at once (see the scenario below). Only when the working set is known and bounded should you raise the limit, and then for that process only.

Worked scenario

A nightly job totals transactions from a newline-delimited JSON export. It ran for months, then started failing as the export grew:

const fs = require('node:fs');

const lines = fs.readFileSync('events.ndjson', 'utf8').split('\n').filter(Boolean);
const rows = lines.map((l) => JSON.parse(l));
const total = rows.reduce((sum, r) => sum + r.amount, 0);
console.log({ rows: rows.length, total });
JavaScript

Diagnosis: the file content, an array of every line, and an array of every parsed object are all alive at once, so peak heap is several times the file size. Reproduced with a 64 MB, 600,000-line file and --max-old-space-size=64, it crashes with the fatal error every time. That is a working-set problem, not a leak: the job only needs one row at a time.

Fix: stream the file and keep only the running totals.

const fs = require('node:fs');
const readline = require('node:readline');

async function main() {
  const rl = readline.createInterface({
    input: fs.createReadStream('events.ndjson'),
    crlfDelay: Infinity,
  });
  let rows = 0;
  let total = 0;
  for await (const line of rl) {
    if (!line) continue;
    total += JSON.parse(line).amount;
    rows++;
  }
  console.log({ rows, total });
}

main();
JavaScript

With the same 64 MB heap limit, it completes and prints { rows: 600000, total: 298722109 }, with a peak resident size of about 66 MB. Memory now depends on the longest line, not the file size, so next year’s bigger export will not bring the error back. The same idea applies to databases (cursors or keyset pagination instead of loading every row) and HTTP (piping response streams instead of await res.json() on huge payloads).

Common mistake

The most common answer online is NODE_OPTIONS=--max-old-space-size=8192. For a build tool on a large codebase, that can be the right call. For a server, it usually turns a crash after two hours into a crash after sixteen, and makes each garbage collection pause longer along the way. A leak grows until it hits whatever limit you set.

In containers, it can be worse than useless. If the container has 1 GiB and Node is told it may use 4 GiB of heap, V8 keeps growing past the container limit and the kernel kills the process with SIGKILL: no fatal error, no snapshot, just exit code 137. Node’s own documentation suggests that on a machine with 2 GiB of memory you set the limit to about 1536 MB to leave room for everything else. Apply the same headroom to container limits, since Buffers, native modules and the runtime itself live outside the heap.

Also avoid exporting NODE_OPTIONS globally in a shell profile or CI environment. Every Node process inherits it, including npm and other tools, which hides which program actually needed the memory.

Verify the behavior

  • Run the fixed code with a heap limit close to your target environment, for example node --max-old-space-size=256 job.js, against production-sized input. It should complete.
  • For a leak fix, run a load test for at least several times the old crash interval and confirm heapUsed returns to a stable level after GCs instead of trending upward.
  • Take two snapshots under steady load and compare them; the previously growing constructor should no longer grow.
  • In containers, confirm heap_size_limit inside the running container is below the memory limit: node -p "v8.getHeapStatistics().heap_size_limit / 1048576".

Interview exercise

“A Node API in Kubernetes restarts every few hours. Sometimes the logs show JavaScript heap out of memory, sometimes the pod is just OOMKilled with no message. How do you investigate, and what is the difference between those two outcomes?”

Answer and reasoning

The heap message means V8 reached its own --max-old-space-size limit first and aborted. OOMKilled with no message means the container’s memory limit was reached first and the kernel sent SIGKILL; that happens when the heap limit is above the container limit, or when memory outside the heap (Buffers, native addons) is what grows. Seeing both suggests the two limits are close together and the configuration is ambiguous.

I would first set the heap limit clearly below the container limit, so the failure mode is consistently the V8 error, which is the one that can produce evidence. Then I would add periodic process.memoryUsage() logging to see whether heapUsed or external/rss is growing, and enable --heapsnapshot-near-heap-limit=1 on one replica with a writable volume. Comparing snapshots in DevTools shows which objects accumulate and their retainer path, for example a module-level cache keyed by request ID. The fix is to bound or remove that retention; increasing memory only changes how often the restart happens. This answer shows that you know which limit fired, why the order of limits matters, and that diagnosis comes before tuning.

Continue learning

More in Node.js

read ✓Node.js · mid

Node.js Response Compression with zlib

Compress HTTP responses with gzip or brotli streams, negotiate the encoding, and avoid compressing data that is already compressed.

~2 min readread →
esc