RE:NODE

Sizing13 min read

Node.js memory limits: heap, RSS and the 2 GB ceiling

Why a Node app dies at 2 GB on a 4 GB plan: the V8 heap limit against the container limit, how to set --max-old-space-size, and how to find the real leak.

Updated

0 readers

A Node process on a 4 GB plan that dies while the panel graph shows 2 GB free is not a hosting bug. Node has its own heap ceiling, decided at startup by a heuristic that does not read your plan, and it is frequently well below what you are paying for. The mirror image is just as common and looks nothing like it: a heap set to the full container size, which guarantees the kernel kills the process before V8 ever gets a chance to collect garbage. Both produce "out of memory", they have opposite fixes, and the two crashes are trivially distinguishable once you know what to look for. This is how to tell which one you have, set the number correctly, and work out whether you needed to raise anything at all.

Two ceilings, and which one you hit first#

The container limit is what the host gives the server. It is a cgroup setting, enforced by the kernel, and it counts the whole process: the JavaScript heap, every Buffer, everything a native module allocated, thread stacks, the JIT's compiled code and the Node binary itself. Cross it and the kernel sends SIGKILL. There is no warning, no stack trace and no opportunity for your code to log anything, because the process is not asked to stop - it is stopped.

The V8 heap limit is what Node allows its own JavaScript objects to occupy. It is enforced by V8, not the kernel. When V8 cannot free enough to satisfy an allocation, it gives up deliberately, prints several paragraphs and aborts.

killed at the lineV8 old space--max-old-space-sizeBuffers and nativeoutside the heapThreads and codelibuv, JIT, binaryProcess RSSwhat the kernel countsContainer limitthe plan you bought
Two ceilings above one Node process

The useful consequence: the JavaScript heap is a subset of the process, and --max-old-space-size only governs that subset. An app that streams file uploads, resizes images or holds large Buffer objects can sit at 300 MB of heap and 2.5 GB of RSS. Sizing the plan from heapUsed alone is how that app gets killed on a plan that looks twice as big as it needs.

What is actually inside a Node process#

process.memoryUsage() returns five numbers, and each answers a different question:

javascript
{  rss: 215547904,        // 205 MB - the whole process in physical memory  heapTotal: 132513792,  // 126 MB - heap V8 has committed from the OS  heapUsed: 118426160,   // 113 MB - live objects plus uncollected garbage  external: 8451234,     //   8 MB - C++ objects tied to JS objects  arrayBuffers: 4210688  //   4 MB - ArrayBuffers and Buffers, part of external}
FieldCounts against the heap limitCounts against the container limit
heapUsedYesYes
heapTotalYesYes
externalNoYes
arrayBuffersNoYes
rssNot applicableThis is the number the kernel watches

Log all five once a minute in production. It costs nothing and it turns "it died again" into a shape you can read. The single most valuable pattern it reveals is rss climbing while heapUsed stays flat, which means the leak is in buffers or a native addon and no amount of heap tuning will touch it.

Things that live in RSS and never appear in heapUsed: Buffer contents, anything allocated by a native module such as an image or crypto library, the four libuv thread-pool threads (UV_THREADPOOL_SIZE, default 4) and their stacks, the compiled output of the JIT, the loaded binary, and allocator fragmentation. The last one is real and frequently misdiagnosed - glibc's malloc keeps per-thread arenas and does not always return freed pages to the kernel, so RSS can plateau above what the application is using. Setting MALLOC_ARENA_MAX=2 in the environment sometimes reduces it; it is worth one experiment, not an afternoon.

Finding your real heap limit in one command#

Do not guess the default. It has changed between Node major versions, and from Node 12 onward it is derived from how much memory Node believes the machine has - which, inside a container, is usually the whole host rather than your plan. On a 1 GB container running on a 128 GB node, Node can happily choose a heap ceiling several times your limit, and the kernel then kills you long before V8 does anything about it.

Ask the runtime instead:

bash
$ node -p "require('v8').getHeapStatistics().heap_size_limit / 1048576"2048.0000152587891$ node --max-old-space-size=3072 -p "require('v8').getHeapStatistics().heap_size_limit / 1048576"3075.0000152587891

The second number is slightly above the flag because heap_size_limit includes the young generation as well as old space. Two more one-liners are worth knowing:

bash
$ node -p "require('os').totalmem() / 1073741824"   # the machine, not your plan$ node -p "process.constrainedMemory()"             # the cgroup limit, in bytes

process.constrainedMemory() exists on Node 18.15 and later and returns the memory limit the container reports, or 0 when there is no limit. On older versions it is not defined, and os.totalmem() is the only thing available - which is exactly the trap described above. If your process manager or Dockerfile makes decisions from os.totalmem(), check them.

Setting the limit properly#

Three places will do it, and they are not equivalent:

bash
$ node --max-old-space-size=3072 dist/server.js
the panel Startup tab, or .env
NODE_OPTIONS=--max-old-space-size=3072
package.json
{  "scripts": {    "start": "node --max-old-space-size=3072 dist/server.js"  }}

NODE_OPTIONS applies to every Node process the environment starts, including npm, next build, tsc and any child process. That is often what you want - a Next.js build that dies partway through is the classic case - but remember that a build step and the running server then share the same number.

Set the heap to roughly 75% of the container limit. Never set it equal to the plan.

Container limit--max-old-space-sizeWhat the rest is for
512 MB320Runtime, stacks, small buffers
1 GB768As above, plus modest caches
2 GB1536Room for a few large requests
4 GB3072Normal headroom
8 GB6144Normal headroom

Leave more than that if you handle uploads, generate images, or use a native module that allocates outside the heap. A service that buffers a 200 MB file per request needs the concurrency multiplied into the headroom, not the average.

Two adjacent flags are occasionally worth touching. --max-semi-space-size=32 raises the young generation, which helps applications with very high allocation rates of short-lived objects - fewer objects get promoted into old space and survive collection that they should not have survived. Two semi-spaces are allocated, so the real cost is at least double the number you set. Only reach for it after --trace-gc shows constant scavenges with a rising promotion rate.

And if you run worker threads or a cluster, every one of them has its own heap:

javascript
new Worker("./job.js", {  resourceLimits: { maxOldGenerationSizeMb: 512, maxYoungGenerationSizeMb: 32 },});

Four cluster workers inheriting --max-old-space-size=3072 from NODE_OPTIONS on a 4 GB plan is a guaranteed kill, and it is a very easy mistake to make when moving from one process to several. Divide the budget. PM2 versus a hosting panel covers when you want several processes at all, which on a single-core plan is usually never.

The two crash signatures, and telling them apart#

The V8 heap limit, in full:

code
<--- Last few GCs --->[18:0x5a3b0f0]   109238 ms: Mark-Compact 2010.4 (2078.3) -> 2009.1 (2080.0) MB[18:0x5a3b0f0]   110114 ms: Mark-Compact 2011.0 (2080.0) -> 2010.2 (2081.5) MB<--- JS stacktrace --->FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed -JavaScript heap out of memory

The container limit, in full:

code
2026-05-04T01:58:12.004Z  GET /api/reports/daily 200 1182ms

That is the whole signature: output stops mid-stream and the process is gone. Exit codes settle the question when the console has scrolled:

Exit codeSignalMeaning
137SIGKILL (128 + 9)Killed from outside. On a container, almost always the memory limit
134SIGABRT (128 + 6)V8 aborted itself. The heap limit, or an assertion
139SIGSEGV (128 + 11)Segmentation fault, usually a native module
143SIGTERM (128 + 15)A clean stop was requested. Your restart button, or a deploy
1-Your code threw and nothing caught it

Two more failures wear the same costume without being either ceiling:

  • RangeError: Invalid string length is V8's maximum string size, not memory exhaustion. Check the real value with node -p "require('buffer').constants.MAX_STRING_LENGTH". It means you concatenated or JSON.stringify'd something that should have been streamed.
  • ERR_WORKER_OUT_OF_MEMORY is one worker thread exceeding its own resourceLimits, which does not touch the main process's heap at all.

On RE:NODE the container-limit case has a specific and deliberate shape. At the memory limit the kernel stops the container and it comes back clean rather than being allowed to swap, which upstream Pterodactyl does the opposite way round. A short restart that recovers by itself beats a slow decline that drags the machine down. The consequence to plan for is that a leaking app becomes a restart loop, and a watcher polls every two minutes for uptime that went backwards: three unexpected restarts in an hour raises a warning on the server page and opens a ticket automatically. Why your game server keeps restarting goes through the same mechanism from the other end.

Finding the leak instead of raising the ceiling#

An app that grows until it hits any ceiling has a leak, and raising the ceiling buys hours rather than fixing it. If memory climbs steadily under constant load, the number to change is in your code, not in your start command.

Start with --trace-gc, which is nearly free and can run in production:

bash
$ node --trace-gc --max-old-space-size=1536 dist/server.js
code
[42:0x5f8c0a0]     3021 ms: Scavenge 58.3 (79.5) -> 44.1 (80.5) MB, 2.1 / 0.0 ms[42:0x5f8c0a0]   184433 ms: Mark-Compact 512.9 (548.0) -> 498.2 (549.5) MB, 220.4 / 0.0 ms[42:0x5f8c0a0]   402117 ms: Mark-Compact 899.1 (932.0) -> 884.7 (933.5) MB, 388.9 / 0.0 ms

Ignore the Scavenge lines. Read the number immediately after the arrow on successive Mark-Compact lines: that is the live heap after a full collection. If it returns to roughly the same floor each time, you do not have a leak and you may genuinely need a bigger heap. If it rises monotonically over hours - 498, 884, 1,340 - something is being retained, and no setting fixes that.

When you have established it is a leak, take heap snapshots:

bash
$ node --heapsnapshot-signal=SIGUSR2 dist/server.js$ kill -USR2 $(pgrep -f dist/server.js)

Each signal writes a .heapsnapshot file into the working directory. You can also do it from inside the process, which is easier on a panel where sending signals is awkward:

javascript
const v8 = require("node:v8");const path = v8.writeHeapSnapshot(`/home/container/heap-${Date.now()}.heapsnapshot`);console.log("snapshot written to", path);

Take one after warm-up and another thirty to sixty minutes later under the same load. Download both, open Chrome DevTools, go to the Memory panel, load both files, then switch the view to Comparison and sort by delta. The constructor whose object count grew by tens of thousands is the leak, and the retainers pane tells you what is holding it. Two snapshots are the entire technique; one snapshot tells you almost nothing.

Where the memory usually goes#

In roughly the order these turn up in real applications:

  • An unbounded cache. A module-scope Map or plain object that only ever gets written to. Give it a size limit and an eviction policy; an LRU with a max is twenty lines or one dependency.
  • Event listeners added per request or per connection and never removed. The MaxListenersExceededWarning in your log is Node telling you this is happening, and it is almost never a false alarm.
  • Timers holding closures. A setInterval created per connection and never cleared keeps everything its callback closes over, including request bodies and database rows, alive forever.
  • Pending-request maps with no timeout. Keyed by correlation id, deleted on response, never deleted when the response does not come.
  • Streams that are not consumed or not destroyed. Ignoring backpressure - writing faster than the destination accepts - buffers the difference in memory by design.
  • Discord bot caches. discord.js keeps guilds, channels, members, messages and presences in memory. The defaults suit a bot in a handful of servers; member caches in particular grow with every guild you join. Configure makeCache and sweepers, and request only the gateway intents you actually use. Hosting a Discord bot 24/7 and picking a plan for a Discord bot go through the sizing.
  • Native modules. Image processing, headless browsers and encoding libraries allocate outside the heap. These show up as rss and external climbing with heapUsed flat, and they need their own concurrency limit rather than a bigger heap.

Sizing a plan, with a worked example#

Rough starting points for a single process, before you measure your own:

WorkloadTypical RSSSensible plan
Discord bot, a few guilds80-150 MB1 GB
Discord bot, dozens of guilds250-600 MB2 GB
Express or Fastify API, moderate traffic150-400 MB1-2 GB
Next.js in production (next start)300-700 MB2 GB
next build or a large tsc run1.5-4 GB at peakBuild where you have the room
Image or media processing500 MB upwards, per jobDepends entirely on input size

Now a real shape. An Express API on a 2 GB plan died at about 02:00 most nights. The console ended mid-request, the exit code was 137, and there was nothing in the application log. NODE_OPTIONS had been set to --max-old-space-size=2048, matching the plan exactly, which is the mistake: the heap was permitted to fill the entire container, so V8 had no reason to collect aggressively and the kernel arrived first.

The minute-by-minute process.memoryUsage() log showed heapUsed flat at 180 MB all day and then a vertical climb to 1.7 GB starting at 01:55. At 01:55 a scheduled job pulled four hundred thousand rows, mapped them into objects, and JSON.stringify'd the result into a single string to write a report.

Three changes, none of them a bigger plan. The heap came down to 1536, so that V8 hits its own ceiling first and aborts with a message you can read instead of vanishing. The report job moved to a cursor and a write stream, so rows are never all in memory at once. And the job moved out of the API process entirely - see background jobs on a small server for the patterns. Peak RSS afterwards was 620 MB on the same 2 GB plan.

The general rule the example illustrates: before buying memory, make the process fail in a way that prints something. A 134 with a stack trace is worth more than a 137 with silence, even though 137 is the friendlier-looking crash.

FAQ#

What does --max-old-space-size actually set?

The maximum size in megabytes of V8's old generation - the part of the JavaScript heap where objects live after they survive a couple of young-generation collections. It does not limit Buffer data, native allocations or thread stacks, so the process can and will use more memory than the number you set.

What is a safe value on a 4 GB plan?

Around 3072. The aim is for V8 to hit its own ceiling before the kernel hits yours, so you get a readable error instead of a silent kill. If your app moves large buffers around, go lower and watch rss rather than heapUsed.

My app is killed but there is nothing in the log. Why?

Because it was killed with SIGKILL by the kernel for exceeding the container memory limit, and a process receiving SIGKILL cannot run any code, including a logger. Check the exit code: 137 is this case. Reading the console covers what an abrupt ending looks like next to a genuine crash.

Does raising the heap limit fix a memory leak?

No. It delays the crash in proportion to how much you raised it. If the live heap after each full garbage collection rises steadily under constant load, the retained objects are a bug, and the only real fix is finding what holds them.

Do cluster workers share the heap limit?

No. Every process gets its own, and NODE_OPTIONS applies the same value to all of them. Four workers at 3 GB each on a 4 GB plan is four times the plan. Divide your container limit by the number of processes before setting the flag.

Why does RSS stay high after a burst of traffic?

Partly because V8 does not return freed heap pages to the operating system immediately, and partly because the system allocator holds onto arenas. A plateau is normal. A staircase that only ever goes up, across days, is not - that is the graph shape to act on, and reading a server load graph covers the rest of them.


Comments

Completely anonymous: no account, no email, no cookie. We store the name you type, the text and the time - nothing else. Links are limited and markup is not rendered.

0/2000