Skip to content

Fix: Node.js JavaScript Heap Out of Memory

FixDevs · (Updated: )

Part of:  JavaScript & TypeScript Errors

Quick Answer

How to fix Node.js 'JavaScript heap out of memory' — increasing heap size, finding memory leaks with heap snapshots, fixing common leak patterns, and stream-based processing for large data.

The Error

Node.js crashes with an out-of-memory error:

FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory
 1: 0xb7c560 node::Abort() [node]
 2: 0xa914f5 node::FatalError(char const*, char const*) [node]
 3: 0xd886fe v8::Utils::ReportOOMFailure(v8::internal::Isolate*, char const*, bool) [node]
 4: 0xd88a37 v8::internal::V8::FatalProcessOutOfMemory(v8::internal::Isolate*, char const*, bool) [node]

Aborted (core dumped)

Or a more specific message:

FATAL ERROR: CALL_AND_RETRY_LAST Allocation failed - JavaScript heap out of memory

Or the process is killed by the OS without a message (OOM killer on Linux).

Why This Happens

Node.js’s V8 JavaScript engine has a default heap size limit — about 1.5 GB on 64-bit systems (less on 32-bit). When the heap exceeds this limit, Node.js crashes. The limit exists because V8’s garbage collector becomes progressively less efficient as the heap grows. At very large heap sizes, GC pauses can last seconds, freezing the event loop and making the application unresponsive. The crash is V8’s way of saying “I cannot manage this much memory efficiently.”

The actual default varies by Node.js version and platform. Node.js 12-14 defaulted to approximately 1.5 GB. Node.js 16+ uses a dynamic default based on available system memory, typically capping around 2 GB on machines with 4+ GB RAM. On containers with memory limits (Docker, Kubernetes), Node.js may detect the cgroup memory limit and set the heap accordingly — but this detection is not always reliable, especially on older kernels.

Common causes:

  • Memory leak — objects are allocated but never garbage collected because a reference chain keeps them alive. The heap grows without bound.
  • Loading large files into memory — reading an entire CSV, JSON, or log file with fs.readFileSync() or JSON.parse() when the file is larger than available memory.
  • Processing large arrays — building a result array with millions of items instead of streaming or batching.
  • Event listener accumulation — adding event listeners without removing them, usually inside loops or repeated function calls.
  • Caching without eviction — an in-memory cache that grows forever without an LRU or TTL policy.
  • Circular references in closures — closures that reference large objects, keeping them alive even after they’re no longer needed.
  • Recursive processing — deep recursion on large data structures building a large call stack and heap simultaneously.

Fix 1: Increase the Heap Size (Temporary Fix)

Increase Node.js’s maximum heap size to buy time while you find the underlying issue:

# Increase to 4 GB
node --max-old-space-size=4096 server.js

# Or set via environment variable
NODE_OPTIONS="--max-old-space-size=4096" node server.js

# In package.json scripts
{
  "scripts": {
    "start": "node --max-old-space-size=4096 server.js",
    "build": "NODE_OPTIONS=--max-old-space-size=4096 webpack"
  }
}

This is a workaround, not a fix. If the process has a memory leak, it will still crash — just later. Use this to prevent crashes while you diagnose the leak.

Rule of thumb for heap size: set it to ~75% of available RAM, leaving room for the OS and other processes. On a 4 GB server: --max-old-space-size=3072.

Default Heap Sizes Across Runtimes

Different JavaScript runtimes handle heap limits differently:

RuntimeEngineDefault heapOverride flagNotes
Node.js 20+V8~2 GB (dynamic)--max-old-space-size=NDetects cgroup limits in containers
DenoV8~2 GB (dynamic)--v8-flags=--max-old-space-size=NSame V8, different flag syntax
BunJavaScriptCoreSystem RAM limitedBUN_JSC_heapSize=NJSC uses a different GC strategy; tends to use less memory for the same workload

Bun uses WebKit’s JavaScriptCore engine instead of V8. JSC’s garbage collector is concurrent and generational but takes a different approach to heap growth. In practice, Bun processes often use 20-40% less memory than equivalent Node.js processes because JSC is more aggressive about releasing intermediate allocations. If you are hitting heap limits on a memory-constrained environment and your code is compatible with Bun, switching runtimes may solve the problem without any code changes.

Deno uses V8 like Node.js, so the heap behavior is nearly identical. The main difference is that Deno’s --v8-flags syntax requires you to pass V8 flags explicitly, and the flag format uses = (e.g., --v8-flags=--max-old-space-size=4096).

Fix 2: Profile Memory with Heap Snapshots

Find the leak by taking heap snapshots before and after the memory grows:

Using Node.js built-in v8.writeHeapSnapshot():

const v8 = require('v8');

// Take a snapshot at startup
v8.writeHeapSnapshot();

// Run your workload...

// Take another snapshot after memory grows
v8.writeHeapSnapshot();

// Compare the two snapshots in Chrome DevTools

Open Chrome DevTools Memory panel, load the .heapsnapshot files, and use the Comparison view. Objects that grew between snapshot 1 and snapshot 2 are your leak candidates.

Trigger snapshot via HTTP endpoint (for production diagnosis):

const v8 = require('v8');
const path = require('path');

app.get('/debug/heap-snapshot', (req, res) => {
  // Protect this endpoint — internal/admin only!
  const filename = v8.writeHeapSnapshot(path.join('/tmp', `heap-${Date.now()}.heapsnapshot`));
  res.json({ snapshot: filename });
});

Heap Profiling Tools Compared

ToolWhat it doesBest for
Chrome DevTools MemoryHeap snapshots, allocation timelines, comparison viewInteractive exploration of retained objects
clinic.js heapprofilerFlame graph of heap allocations over timeIdentifying which functions allocate the most memory
0xCPU flame graph (not heap-specific)Finding hot paths that may indirectly cause allocations
v8.writeHeapSnapshot()Programmatic snapshot from inside the processProduction debugging where you cannot attach DevTools
--heap-prof flagV8 heap sampling profilerLow-overhead profiling in production; generates .heapprofile files

clinic.js is particularly useful because it runs your application, collects data, and generates an HTML report you can open in a browser:

npm install -g clinic
clinic heapprofiler -- node server.js
# Generates an HTML flame graph of heap allocations

The --heap-prof flag (Node.js 12+) generates a .heapprofile file that can be loaded into Chrome DevTools. It uses V8’s sampling profiler, which has minimal performance overhead and is safe to run in production:

node --heap-prof server.js
# After the process exits, load the .heapprofile in Chrome DevTools

Fix 3: Fix Event Listener Leaks

Adding event listeners inside functions that are called repeatedly without removing them causes the listener count (and referenced objects) to grow indefinitely:

// LEAK — each call to setupHandler adds a new listener
function setupHandler(emitter) {
  emitter.on('data', (data) => {
    // This closure captures 'data' and prevents GC
    processData(data);
  });
}

// Called many times — listeners pile up
setInterval(() => setupHandler(eventEmitter), 1000);
// FIX — remove the listener when done
function setupHandler(emitter) {
  const handler = (data) => processData(data);
  emitter.on('data', handler);

  // Return cleanup function
  return () => emitter.off('data', handler);
}

// Or use 'once' for single-use listeners
emitter.once('data', handler);

Detect listener leaks early:

// Node.js warns when more than 10 listeners are added to one event
// Increase the limit if you legitimately need more
emitter.setMaxListeners(20);

// Or check current listener count
console.log(emitter.listenerCount('data'));

Fix 4: Stream Large Files Instead of Loading Them

Reading large files entirely into memory is the most common cause of crashes in data processing scripts:

// CRASHES for large files — loads entire file into memory
const data = fs.readFileSync('huge-file.csv', 'utf8');
const rows = data.split('\n');
// 10 GB file — 10 GB in memory

// FIX — process line by line with streams
const readline = require('readline');
const fs = require('fs');

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

rl.on('line', (line) => {
  processRow(line);  // Process one line at a time — minimal memory
});

rl.on('close', () => {
  console.log('Done processing');
});

For JSON files too large to parse at once, use stream-json:

npm install stream-json
const { parser } = require('stream-json');
const { streamArray } = require('stream-json/streamers/StreamArray');

fs.createReadStream('huge.json')
  .pipe(parser())
  .pipe(streamArray())
  .on('data', ({ key, value }) => {
    processItem(value);  // One item at a time
  })
  .on('end', () => console.log('Done'));

Streaming vs Buffering: When Each Applies

Streaming is not always the right answer. Some operations genuinely require the entire dataset in memory (sorting, deduplication across the full set, computing aggregates that depend on all rows). For those cases, the solution is not “stream harder” but “process in batches and merge results.”

Pattern: Sort a file larger than memory:

// Split into sorted chunks, then merge
const { Transform } = require('stream');

async function externalSort(inputFile, outputFile, chunkSize = 100_000) {
  const chunks = [];
  let currentChunk = [];

  const rl = readline.createInterface({
    input: fs.createReadStream(inputFile),
  });

  for await (const line of rl) {
    currentChunk.push(line);
    if (currentChunk.length >= chunkSize) {
      currentChunk.sort();
      const tmpFile = `/tmp/chunk-${chunks.length}.txt`;
      fs.writeFileSync(tmpFile, currentChunk.join('\n'));
      chunks.push(tmpFile);
      currentChunk = [];
    }
  }
  // ... merge sorted chunks with a priority queue
}

Fix 5: Batch Large Database Queries

Fetching millions of rows from a database at once fills the heap:

// CRASHES for large tables
const allUsers = await db.query('SELECT * FROM users');
// 1M users x 500 bytes each = 500 MB in memory

// FIX — process in batches
async function processAllUsers() {
  const batchSize = 1000;
  let offset = 0;

  while (true) {
    const batch = await db.query(
      'SELECT * FROM users ORDER BY id LIMIT $1 OFFSET $2',
      [batchSize, offset]
    );

    if (batch.rows.length === 0) break;

    for (const user of batch.rows) {
      await processUser(user);
    }

    offset += batchSize;
    console.log(`Processed ${offset} users`);
  }
}

For PostgreSQL, use cursors for true streaming:

const cursor = client.query(new Cursor('SELECT * FROM users ORDER BY id'));

async function processWithCursor() {
  while (true) {
    const rows = await cursor.read(100);  // Read 100 rows at a time
    if (rows.length === 0) break;
    for (const row of rows) await processUser(row);
  }
  await cursor.close();
}

Fix 6: Implement Cache Eviction

Caches without eviction grow until the process crashes:

// LEAK — cache grows without bound
const cache = new Map();

function getCachedData(key) {
  if (cache.has(key)) return cache.get(key);
  const value = expensiveCompute(key);
  cache.set(key, value);  // Never evicted
  return value;
}

Fix with a size-limited LRU cache:

npm install lru-cache
const { LRUCache } = require('lru-cache');

const cache = new LRUCache({
  max: 1000,          // Maximum 1000 entries
  maxSize: 50_000_000, // Maximum 50 MB total
  sizeCalculation: (value) => JSON.stringify(value).length,
  ttl: 1000 * 60 * 60, // Entries expire after 1 hour
});

function getCachedData(key) {
  if (cache.has(key)) return cache.get(key);
  const value = expensiveCompute(key);
  cache.set(key, value);
  return value;
}

Use WeakMap for object-keyed caches — entries are automatically garbage collected when the key object is no longer referenced:

const resultCache = new WeakMap();

function getCachedResult(obj) {
  if (resultCache.has(obj)) return resultCache.get(obj);
  const result = compute(obj);
  resultCache.set(obj, result);  // Automatically GC'd when obj is GC'd
  return result;
}

Fix 7: Monitor Memory in Production

Detect memory growth before it causes a crash:

// Log memory usage periodically
setInterval(() => {
  const usage = process.memoryUsage();
  console.log({
    heapUsed: `${Math.round(usage.heapUsed / 1024 / 1024)} MB`,
    heapTotal: `${Math.round(usage.heapTotal / 1024 / 1024)} MB`,
    rss: `${Math.round(usage.rss / 1024 / 1024)} MB`,
    external: `${Math.round(usage.external / 1024 / 1024)} MB`,
  });
}, 30000);  // Every 30 seconds

Auto-restart on memory threshold:

const MEMORY_LIMIT_MB = 1024;  // Restart if heap exceeds 1 GB

setInterval(() => {
  const heapMB = process.memoryUsage().heapUsed / 1024 / 1024;
  if (heapMB > MEMORY_LIMIT_MB) {
    console.error(`Memory limit exceeded (${Math.round(heapMB)} MB). Restarting...`);
    process.exit(1);  // PM2 or Docker will restart the process
  }
}, 10000);

Use PM2 with memory restart limit:

pm2 start server.js --max-memory-restart 1G
# PM2 restarts the process if it exceeds 1 GB

Still Not Working?

Force a garbage collection to separate “memory leak” from “high but stable memory usage”:

node --expose-gc server.js
// Trigger GC manually (only works with --expose-gc flag)
global.gc();
console.log('After GC:', process.memoryUsage().heapUsed / 1024 / 1024, 'MB');

If memory drops significantly after forced GC, the issue is that GC isn’t running frequently enough (a GC tuning problem, not a leak). If memory stays high after GC, objects are being retained by references (a real leak).

Check --max-semi-space-size — the young generation heap also has a limit. For write-heavy workloads:

node --max-semi-space-size=128 --max-old-space-size=4096 server.js

Use clinic.js for production-quality profiling:

npm install -g clinic
clinic heapprofiler -- node server.js
# Generates a flame graph of heap allocations

Watch for ArrayBuffer and Buffer memory. Node.js reports external memory separately from heapUsed. If external is growing but heapUsed is stable, the leak is in native buffers (Buffers, typed arrays, WASM memory), not in JavaScript objects. Heap snapshots won’t show these. Use process.memoryUsage().external and process.memoryUsage().arrayBuffers to track them.

Check for leaked timers. setInterval and setTimeout callbacks hold references to their closure scope. A forgotten interval that runs every second and accumulates results will grow the heap indefinitely. Use clearInterval / clearTimeout in cleanup logic, and audit your code for intervals that never get cleared:

// LEAK — interval never cleared, results array grows forever
const results = [];
setInterval(async () => {
  const data = await fetchData();
  results.push(data);  // Array grows without bound
}, 1000);

Consider --max-old-space-size=0 for diagnostics. Setting the heap limit to 0 lets V8 use its internal dynamic sizing. On some Node.js versions and platforms, explicitly setting a value overrides the automatic container detection. Removing the flag entirely may give you a better default on Kubernetes or Docker.

For related Node.js issues, see Fix: Node.js Unhandled Rejection Crash, Fix: Linux OOM Killer, Fix: Node Stream Error, and Fix: Bun Not Working.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles