๐ŸŒ Detecting your locationโ€ฆ

How to Fix ‘JavaScript heap out of memory’ Error in Node.js

โฑ๏ธ7 min read  ยท  1,374 words

FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory means V8 hit its heap ceiling. There are two very different situations behind it: a legitimate workload that needs more memory, and a leak or an algorithm loading far more than it should. Raising the limit fixes the first and hides the second.

First: Which Situation Is This?

Answer this before changing anything.

Symptom Likely cause
Fails during a build or a large batch job Legitimately needs more heap
Server crashes after hours or days of uptime Memory leak
Fails on large input, fine on small Loading everything into memory instead of streaming
Crashes immediately at any size Unbounded recursion or an infinite accumulation

A long-running server that grows steadily has a leak. Raising the limit only postpones the crash.

Quick Fix: Raise the Heap Limit

Appropriate for builds and batch jobs that genuinely need the memory.

# Per invocation, in megabytes
node --max-old-space-size=4096 script.js

# For anything Node spawns, including build tools
export NODE_OPTIONS="--max-old-space-size=4096"
npm run build
{
  "scripts": {
    "build": "NODE_OPTIONS=--max-old-space-size=4096 next build"
  }
}

Do not set this above the memory actually available. If the container has 2GB and you allow a 4GB heap, the kernel’s OOM killer terminates the process with SIGKILL โ€” and you get no JavaScript error at all, just an exit code of 137, which is much harder to diagnose.

# Exit code 137 = 128 + 9 (SIGKILL) โ€” killed by the OS, not by V8
docker inspect <container> --format='{{.State.ExitCode}}'
dmesg | grep -i "killed process"

Cause 1: Reading Whole Files Into Memory

The most common cause in data-processing code.

// โŒ A 2GB file needs 2GB+ of heap, plus overhead for the string
import fs from 'node:fs/promises';
const content = await fs.readFile('huge.csv', 'utf8');
const lines = content.split('\n');       // now a second copy exists
// โœ… Constant memory regardless of file size
import fs from 'node:fs';
import readline from 'node:readline';

const stream = fs.createReadStream('huge.csv', { encoding: 'utf8' });
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });

for await (const line of rl) {
  await processLine(line);
}

The same applies to HTTP responses and database results. Stream them; do not accumulate them.

// โŒ Buffers the entire response
const res = await fetch(url);
const buffer = await res.arrayBuffer();

// โœ… Pipes straight to disk
import { pipeline } from 'node:stream/promises';
import { Writable } from 'node:stream';

await pipeline(res.body, fs.createWriteStream('output.bin'));

Cause 2: Unbounded Query Results

// โŒ Ten million rows, all resident at once
const users = await db.query('SELECT * FROM users');
for (const user of users) await sendEmail(user);
// โœ… Keyset-paginated batches โ€” constant memory
let lastId = 0;
const BATCH = 1000;

while (true) {
  const rows = await db.query(
    'SELECT * FROM users WHERE id > $1 ORDER BY id LIMIT $2',
    [lastId, BATCH]
  );
  if (rows.length === 0) break;

  for (const user of rows) await sendEmail(user);
  lastId = rows[rows.length - 1].id;
}

Better still, use a database cursor where your driver supports one โ€” it streams rows without holding the full result set on either side.

Cause 3: Genuine Leaks in Long-Running Servers

Four patterns account for most of them.

An unbounded cache. A plain object or Map that only ever grows.

// โŒ Grows forever
const cache = new Map();
function get(key) {
  if (!cache.has(key)) cache.set(key, expensive(key));
  return cache.get(key);
}
// โœ… Bounded with eviction
import { LRUCache } from 'lru-cache';
const cache = new LRUCache({ max: 5000, ttl: 1000 * 60 * 10 });

Listeners that are never removed.

// โŒ Adds a listener on every request
app.get('/data', (req, res) => {
  emitter.on('update', () => res.write('...'));
});

// โœ… Remove it when the request ends
app.get('/data', (req, res) => {
  const onUpdate = () => res.write('...');
  emitter.on('update', onUpdate);
  res.on('close', () => emitter.off('update', onUpdate));
});

Node warns about this โ€” MaxListenersExceededWarning is a leak indicator, not noise to suppress.

Timers that are never cleared. Every setInterval holds its closure and everything it references alive forever.

Closures capturing large objects. A callback that references one field of a huge object keeps the entire object reachable.

Finding a Leak with Heap Snapshots

Guessing is slow. Take snapshots and compare.

# Start with the inspector attached
node --inspect server.js
# Open chrome://inspect in Chrome, click "inspect", go to the Memory tab

The procedure: take a snapshot, run the suspect workload for a while, take a second snapshot, then in the comparison view sort by “Delta”. Objects that grow and never shrink between snapshots are your leak, and the retainer tree shows exactly what is keeping them alive.

You can also trigger snapshots from inside the process, which is useful in production.

import v8 from 'node:v8';
import fs from 'node:fs';

process.on('SIGUSR2', () => {
  const file = `/tmp/heap-${Date.now()}.heapsnapshot`;
  fs.writeFileSync(file, v8.getHeapSnapshot());
  console.log('Heap snapshot written to', file);
});
// Then: kill -SIGUSR2 <pid>

Log heap usage continuously to confirm a leak before hunting for it.

setInterval(() => {
  const m = process.memoryUsage();
  console.log({
    rss:       `${(m.rss / 1e6).toFixed(0)}MB`,
    heapUsed:  `${(m.heapUsed / 1e6).toFixed(0)}MB`,
    heapTotal: `${(m.heapTotal / 1e6).toFixed(0)}MB`,
    external:  `${(m.external / 1e6).toFixed(0)}MB`,
  });
}, 30_000);

A sawtooth pattern is healthy โ€” garbage collection reclaiming memory. A staircase that only rises is a leak.

Cause 4: Builds Running Out of Memory

Large TypeScript, webpack, or Next.js builds are memory-hungry and frequently fail in CI containers with modest limits.

# Give the build more headroom
NODE_OPTIONS=--max-old-space-size=6144 npm run build

# TypeScript: incremental builds reuse previous work
tsc --incremental --noEmit

# Split very large builds into projects
tsc --build tsconfig.json

In CI, make sure the runner actually has the memory you are allowing. A 6GB heap limit on a 4GB runner produces an OOM kill rather than a helpful error.

Docker and Container Limits

Node does not automatically size its heap to a container limit in every configuration, so set both explicitly.

services:
  api:
    image: my-api
    environment:
      # Keep the heap below the container limit, leaving room for
      # native allocations, buffers, and the runtime itself.
      NODE_OPTIONS: "--max-old-space-size=1536"
    deploy:
      resources:
        limits:
          memory: 2G

The gap between the heap limit and the container limit matters. Buffers, native modules, and the runtime all allocate outside the JavaScript heap, and --max-old-space-size does not account for them.

Diagnostic Sequence

  1. Determine whether it is a V8 error or an OS kill โ€” exit code 137 means the kernel did it.
  2. Log process.memoryUsage() over time. Rising monotonically means a leak.
  3. If it fails on large input only, look for whole-file or whole-result-set loading.
  4. If it is a long-running server, take two heap snapshots and compare deltas.
  5. Check the retainer tree for the growing objects to find what holds them.
  6. Only raise the heap limit once you have established the usage is legitimate.

Frequently Asked Questions

Q: What is the default heap limit?
A: It depends on the Node version and available system memory, and modern versions size it more sensibly than older ones. Check yours with node -e "console.log(v8.getHeapStatistics().heap_size_limit / 1e6)".

Q: Is raising max-old-space-size safe?
A: For builds and batch jobs with genuine memory needs, yes โ€” provided the machine has the memory. For a leaking server it postpones the crash and makes it harder to diagnose.

Q: Why does my app crash with exit code 137 and no error?
A: The kernel’s OOM killer sent SIGKILL, which cannot be caught. Your heap limit exceeds the container limit, or something outside the heap is consuming memory.

Q: Can I force garbage collection?
A: With --expose-gc you can call global.gc(), which is useful for testing whether memory is genuinely retained. It is not a fix โ€” if memory is not reclaimed, something still references it.

Q: Do worker threads help?
A: Each worker gets its own heap, so splitting work across them raises total capacity. It does not fix a leak; it distributes it.

Conclusion

Start by classifying the failure: a V8 heap error on a build usually means the work genuinely needs more memory, while steady growth in a long-running server means a leak. Stream files and paginate query results instead of loading everything at once, bound every cache with size and TTL limits, remove listeners and clear timers when their scope ends, and use heap snapshot comparison to find what is actually being retained. Raise --max-old-space-size only after confirming the usage is legitimate, and always keep it comfortably below the container’s memory limit.

MD Rafikul Islam

Written by

MD Rafikul Islam is a software developer and the editor of TechPulse. He writes about developer tooling, hardware, and the practical decisions that come up in day-to-day engineering work โ€” which laptop to buy, which framework to commit to, why a build broke at 2am. He tests the tools he writes about and says plainly when something is not worth the money. Corrections and corrections requests are welcome at rony.yf25@gmail.com.

โœ๏ธ Leave a Comment

Your email address will not be published. Required fields are marked *

๐ŸŒ Read in:๐Ÿ‡ฌ๐Ÿ‡ง English๐Ÿ‡ฉ๐Ÿ‡ช Deutsch๐Ÿ‡ง๐Ÿ‡ท Portuguรชs๐Ÿ‡ธ๐Ÿ‡ฆ ุงู„ุนุฑุจูŠุฉ๐Ÿ‡ฎ๐Ÿ‡ณ เคนเคฟเคจเฅเคฆเฅ€๐Ÿ‡ง๐Ÿ‡ฉ เฆฌเฆพเฆ‚เฆฒเฆพ