Node.js Heap Out of Memory: Causes and Fixes
You start your app, or run npm run build, and a few minutes later the process dies with FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory. This Node.js heap out of memory error means V8 hit its old-space limit and could not free enough memory to continue, so it aborted the process. Raising the limit sometimes makes the error go away, but if your code is leaking or loading too much data at once, it only delays the crash.
This guide shows how to read the error, apply the quick fix, and then find and remove the real cause with heap snapshots and streaming.
What the Node.js Heap Out of Memory Error Actually Means
V8 splits memory into several spaces. Most of your objects (arrays, maps, parsed JSON, closures) end up in the old space, and V8 caps that space with a heap limit. When garbage collection cannot reclaim enough to stay under the cap, Node prints the fatal error and exits with a non-zero code.
The limit is not a fixed number. It depends on your Node.js version and on how much memory the machine or container reports. You can see the real value on your own setup:
const v8 = require('node:v8');
const limitMb = v8.getHeapStatistics().heap_size_limit / 1024 / 1024;
console.log(`Heap limit: ${Math.round(limitMb)} MB`);
Run that inside the same environment that crashes (same container, same CI runner). A build server or a small Docker container often reports a much lower limit than your laptop, which is why the error shows up "only in production" or "only in CI".
Quick Fix: Raise the Heap Limit
If you need the process to finish now, raise the old-space limit with --max-old-space-size, which takes a value in megabytes:
# one-off run
node --max-old-space-size=4096 server.js
# works for any tool that spawns Node (npm, webpack, next, tsc)
export NODE_OPTIONS="--max-old-space-size=4096"
npm run build
Two rules keep this from backfiring:
- Do not set the limit higher than the memory you actually have. In a container, leave room for buffers and native memory, so about 70 to 75 percent of the container limit is a sensible ceiling.
- Treat it as a stopgap for build tools and one-off scripts. For a long-running server, a higher limit just makes the leak take longer to kill you.
Find the Real Cause With Heap Snapshots
If memory grows steadily while the app runs, you have a leak or unbounded growth. First confirm the trend by logging memory on an interval:
setInterval(() => {
const { heapUsed, rss } = process.memoryUsage();
console.log(
`heapUsed=${(heapUsed / 1048576).toFixed(0)}MB rss=${(rss / 1048576).toFixed(0)}MB`
);
}, 30_000).unref();
If heapUsed climbs and never drops after garbage collection, capture a snapshot right before the crash. Node can do this for you:
node --heapsnapshot-near-heap-limit=2 server.js
This writes .heapsnapshot files as the process approaches its limit (available in current LTS releases). Open them in Chrome DevTools under the Memory tab, switch to the Comparison view between two snapshots, and sort by Size Delta. The constructor names at the top, often Array, Object, Map, or (string), point at the data structure that keeps growing. Follow the retainers panel to see which variable holds on to it.
You can also take a snapshot on demand from code, for example from an admin-only route or a signal handler:
const v8 = require('node:v8');
process.on('SIGUSR2', () => {
const file = v8.writeHeapSnapshot();
console.log(`Heap snapshot written to ${file}`);
});
Keep in mind that writing a snapshot pauses the process and needs extra memory, so do it on a single instance and not on every replica at once.
Common Causes and Their Fixes
Loading an entire large file into memory
fs.readFile plus JSON.parse on a several-hundred-megabyte file holds the raw string and the parsed object at the same time. Stream it instead and process one line at a time:
const fs = require('node:fs');
const readline = require('node:readline');
async function importUsers(path) {
const rl = readline.createInterface({
input: fs.createReadStream(path),
crlfDelay: Infinity,
});
let batch = [];
for await (const line of rl) {
batch.push(JSON.parse(line)); // newline-delimited JSON
if (batch.length === 1000) {
await saveBatch(batch);
batch = [];
}
}
if (batch.length) await saveBatch(batch);
}
Memory stays flat because only one batch of 1,000 rows exists at any moment, no matter how big the file is.
An unbounded in-memory cache
A plain object or Map used as a cache that never evicts anything is one of the most common leaks. Bound it with a maximum size and a time to live:
const { LRUCache } = require('lru-cache');
const cache = new LRUCache({
max: 5000, // maximum entries
ttl: 1000 * 60 * 10 // 10 minutes
});
Listeners and timers that are never removed
Adding an event listener inside a request handler without removing it keeps every request's closure alive. Node warns with MaxListenersExceededWarning when this happens, so do not silence that warning. Remove listeners when the work is done, and clear intervals you create per request.
Building huge arrays in one query
Loading every row of a table with a single SELECT * and mapping over it creates several copies in memory. Use pagination or a database cursor and process rows in chunks.
Build Tools Running Out of Memory
If the crash happens during npm run build, tsc, or a bundler on a large project, the cause is usually just a big module graph rather than a leak. Set the limit in the script itself so every developer and CI job gets the same behavior:
{
"scripts": {
"build": "NODE_OPTIONS=--max-old-space-size=4096 next build"
}
}
Use cross-env if the same script must run on Windows. If the build still fails at a higher limit, split the project, turn off source maps for the failing step, or check whether a plugin is holding onto every module.
Frequently Asked Questions
What is the default Node.js heap size?
There is no single default. It depends on the Node.js version and the memory available to the process, and on 64-bit systems it commonly lands between roughly 2 GB and 4 GB. Check the exact value with v8.getHeapStatistics().heap_size_limit in the environment where the crash happens.
Is it safe to set --max-old-space-size very high?
Not if it exceeds real memory. When the limit is above what the machine or container allows, the operating system kills the process (an OOM kill) before V8 can throw a clean error, and you lose the useful stack trace. Keep the value comfortably below the memory limit.
Why does the error appear only in Docker or CI?
Containers and CI runners often have far less memory than a developer machine, and Node sizes its heap limit from what it sees. Set the limit explicitly and allocate enough memory to the container, then reproduce the problem locally with the same --max-old-space-size value.
How do I know if it is a leak or just a big workload?
Log process.memoryUsage().heapUsed over time. A leak shows a steady climb that survives garbage collection. A big workload shows a spike during one operation, such as a large import, and then falls back down.
Conclusion
Use --max-old-space-size to get unblocked, then treat the crash as a symptom. Log heapUsed to see whether memory grows over time, capture two heap snapshots and compare them, and fix what keeps growing: stream large files, bound your caches, and remove listeners you add. Set an explicit heap limit below your container's memory so failures produce a readable error instead of a silent OOM kill.
0 Comments
No comments yet — be the first to share your thoughts.