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.
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:
{ 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}| Field | Counts against the heap limit | Counts against the container limit |
|---|---|---|
heapUsed | Yes | Yes |
heapTotal | Yes | Yes |
external | No | Yes |
arrayBuffers | No | Yes |
rss | Not applicable | This 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:
$ 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.0000152587891The 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:
$ node -p "require('os').totalmem() / 1073741824" # the machine, not your plan$ node -p "process.constrainedMemory()" # the cgroup limit, in bytesprocess.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:
$ node --max-old-space-size=3072 dist/server.jsNODE_OPTIONS=--max-old-space-size=3072{ "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-size | What the rest is for |
|---|---|---|
| 512 MB | 320 | Runtime, stacks, small buffers |
| 1 GB | 768 | As above, plus modest caches |
| 2 GB | 1536 | Room for a few large requests |
| 4 GB | 3072 | Normal headroom |
| 8 GB | 6144 | Normal 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:
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:
<--- 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 memoryThe container limit, in full:
2026-05-04T01:58:12.004Z GET /api/reports/daily 200 1182msThat is the whole signature: output stops mid-stream and the process is gone. Exit codes settle the question when the console has scrolled:
| Exit code | Signal | Meaning |
|---|---|---|
137 | SIGKILL (128 + 9) | Killed from outside. On a container, almost always the memory limit |
134 | SIGABRT (128 + 6) | V8 aborted itself. The heap limit, or an assertion |
139 | SIGSEGV (128 + 11) | Segmentation fault, usually a native module |
143 | SIGTERM (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 lengthis V8's maximum string size, not memory exhaustion. Check the real value withnode -p "require('buffer').constants.MAX_STRING_LENGTH". It means you concatenated orJSON.stringify'd something that should have been streamed.ERR_WORKER_OUT_OF_MEMORYis one worker thread exceeding its ownresourceLimits, 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:
$ node --trace-gc --max-old-space-size=1536 dist/server.js[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 msIgnore 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:
$ 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:
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
Mapor plain object that only ever gets written to. Give it a size limit and an eviction policy; an LRU with amaxis twenty lines or one dependency. - Event listeners added per request or per connection and never removed. The
MaxListenersExceededWarningin your log is Node telling you this is happening, and it is almost never a false alarm. - Timers holding closures. A
setIntervalcreated 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
makeCacheandsweepers, 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
rssandexternalclimbing withheapUsedflat, 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:
| Workload | Typical RSS | Sensible plan |
|---|---|---|
| Discord bot, a few guilds | 80-150 MB | 1 GB |
| Discord bot, dozens of guilds | 250-600 MB | 2 GB |
| Express or Fastify API, moderate traffic | 150-400 MB | 1-2 GB |
Next.js in production (next start) | 300-700 MB | 2 GB |
next build or a large tsc run | 1.5-4 GB at peak | Build where you have the room |
| Image or media processing | 500 MB upwards, per job | Depends 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.