A live test on PM2 7.0.4: a 100MB spike that lasts 2 seconds and drops back down goes completely unnoticed, while a sustained climb over the same threshold kills the process on the next check - confirmed straight from the daemon's own log line. We read PM2's source (Worker.js, God/Reload.js), unpack why RSS and
Updated: September 18, 2026.
You set max_memory_restart in your PM2 config expecting it to settle the memory question once and for all - and instead it either fails to trigger when the process has clearly bloated, or it fires unexpectedly and drops connections for no obvious reason. Both behaviors trace back to the same root cause: max_memory_restart isn't built the way most people assume. It measures something different from what you'd expect, checks it on a timer instead of continuously, and in fork mode restarts the process the same blunt way a plain pm2 restart does. Below is what it actually does, verified live on PM2 7.0.4, and what to put next to it if you need a limit that's actually hard.
Short version.
max_memory_restartwatches RSS (resident set size) - the process's full physical memory footprint - not the V8 heap that--max-old-space-sizecaps. The check runs on a timer, every 30 seconds by default (confirmed in PM2 7.0.4's source), so a short memory spike that rises and falls between two checks is simply never seen - a live test proved exactly that: a 100MB spike held for 2 seconds passed completely unnoticed. A sustained climb that never drops kills the process on the very next check instead. In fork mode, that restart is a plain stop-then-start cycle - the exact one that already cost about a second of downtime in the zero-downtime deploy article; in cluster mode, workers swap one at a time with no shared gap, with the same tradeoffs already covered there. The limit itself doesn't fix a leak - it just moves the outage onto a predictable schedule instead of an unpredictable one. The genuinely instant hard limit is systemd'sMemoryMax: the kernel stops the process via cgroup the moment it tries to allocate past the cap, with no timer involved - confirmed live with an actual OOM kill.
If you've capped Node.js memory before with --max-old-space-size, you already carry a mental model: one limit, tied to V8 memory, stay under it and you're safe. PM2's max_memory_restart is a limit of an entirely different kind, and mixing the two up is the most common reason its behavior feels arbitrary.
--max-old-space-size caps the old space - one segment of the heap inside V8, the JavaScript engine that runs Node.js, where long-lived objects live. max_memory_restart is compared against RSS (resident set size) - the process's entire physical memory footprint right now: the whole V8 heap, plus buffers allocated outside the heap (the Buffer objects from the leak-diagnosis article live here, not in the V8 heap), native module memory, thread stacks, and Node's own internal bookkeeping. PM2 pulls this number through the npm package pidusage, which on Linux reads /proc/<PID>/status - confirmed by reading ActionMethods.js in the installed PM2 version on the test box.
The live test made the gap obvious on an idle process alone: the test app at rest reported rss: 63332352 (63.3MB), while heapUsed at the same instant was only 6,040,128 (5.8MB). If you were watching heap size alone, that 63MB of RSS would look unaccountable - it isn't measuring the heap at all, it's the whole process, engine internals included.
The practical consequence: --max-old-space-size doesn't protect against whatever kills max_memory_restart, and vice versa. A process leaking mostly through Buffer objects (the exact shape of bug covered in the leak-diagnosis article) can hit an RSS limit while the V8 heap stays nearly flat - from --max-old-space-size's point of view, that process would look completely healthy.
The natural expectation for a "memory limit" is that it fires the instant the threshold is crossed, like a circuit breaker. In practice, the check lives inside a background loop PM2 calls the Worker: it repeatedly polls every running process and, among other things, compares RSS against max_memory_restart. Between two of those polls, PM2 simply isn't looking at memory at all.
The interval is set by the WORKER_INTERVAL constant in the installed version's constants.js (verified on 7.0.4): process.env.PM2_WORKER_INTERVAL || 30000 - 30,000 milliseconds, 30 seconds, by default, overridable via that environment variable when the daemon starts.
To observe the effect within a reasonable window rather than waiting half an hour between attempts, the interval was temporarily shortened to 5 seconds for testing. The logic doesn't change - only the speed at which you can watch it happen.
Short spike. The test app ran under PM2 with max_memory_restart: '80M'. A request to the /spike endpoint made it allocate 100MB and hold it for exactly 2 seconds - well under the poll interval. RSS genuinely crossed the threshold during that window: from 62,232 KB (confirmed by three sources at once - process.memoryUsage().rss inside the app itself, ps -o rss, and VmRSS from /proc/<PID>/status) up to 164,552 KB during the spike and back down to the same 62,232 KB two seconds later. PM2 never reacted: the restart counter stayed at zero, and the PID never changed for the duration of the test.
Sustained climb. Same process, but this time memory is never released - the buffer stays resident, the way an actual leak behaves. On the very next check (within a couple of seconds, given the shortened 5-second interval), the daemon log recorded:
[PM2][WORKER] Process 2 restarted because it exceeds --max-memory-restart value (current_memory=141594624 max_memory_limit=83886080 [octets])
Followed immediately in the same log by Stopping app:memtest id:2, then App [memtest:2] exited with code [0] via signal [SIGINT], and only after that starting in -fork mode- and online.
Scenario | Did RSS cross the threshold? | What PM2 did |
|---|---|---|
100MB spike, held 2 seconds, 5-second poll interval | Yes, confirmed via /proc during the spike | Nothing. restart_time unchanged, same PID |
Same-sized retained growth, never drops | Yes, consistently | Real restart on the next check, confirmed by the daemon log |
The takeaway is simple: max_memory_restart doesn't miss threshold crossings - it misses crossings that end before the next timer tick. For a process that holds a leak for hours, a 30-second delay is irrelevant. For a process whose RSS legitimately spikes under load and drops right back - processing a large upload, a burst of requests - the same interval means the safe, transient spike can just as easily go unnoticed, or land exactly on a poll and trigger an unnecessary restart of a perfectly healthy process. Both outcomes come from the same polling design, not from a bug or randomness.
What happens the instant the limit fires depends on the mode the process runs in - and it's worth going back to the analysis in the zero-downtime deploy article, where this difference is already measured in detail for a plain pm2 restart/reload. The good news: a memory-triggered restart runs through the exact same code, so those measurements carry over directly here without needing to be redone.
In PM2's source, hitting the limit calls God.reloadProcessId. Inside it there's a branch on execution mode: if exec_mode isn't cluster_mode (fork mode, the setup used throughout this series), the call falls straight through to God.restartProcessId - the same path a plain pm2 restart takes: full stop, then a fresh start. That's exactly what the daemon log above showed - "exited... via signal SIGINT", followed by "starting". If you're running a single process without a port-swap setup, as in the series' opening article, that means the same second of downtime already measured for a naive pm2 reload in the zero-downtime article - 11 of 57 requests returning connection refused. The only way to avoid that specifically for a memory-triggered restart is the same blue-green setup with two ports and an nginx switch described there, because PM2 doesn't ask permission before restarting on a memory trigger - it just does it.
Only cluster_mode calls a different function, hardReload: it spawns a new worker first, waits for its listening event, and only then removes the old one - workers swap one at a time, not all at once. That's the same mechanism already covered honestly, tradeoffs included (permanently doubled memory, a per-process ISR cache), in the zero-downtime deploy article - the only thing worth pinning down here is that a memory-triggered restart in cluster mode uses that same mechanism, not some separate, cruder path.
If your Next.js app has a genuine leak - the classic per-request heavy object from the leak-diagnosis article - max_memory_restart won't find it or fix it. It does exactly one thing: compare a number to a number every 30 seconds and kill the process if the number is bigger. Whatever's causing the leak in your code stays there, unchanged.
The practical effect is that the hang becomes regular instead of random. Without the limit, the process would slowly accumulate memory over hours and eventually seize up unpredictably, the way the series opened. With the limit, it hits the memory ceiling on a roughly fixed interval instead - and every single time that happens in fork mode, you get the same second of downtime the rest of this series spent effort removing: an external healthcheck for hangs the memory limit can't see at all, a perimeter through nginx and ufw so only one port is exposed, and a way to restart without dropping connections. A memory limit that quietly restarts the process every few hours with no way to smooth that moment out undoes part of that work if it isn't wired into the same port-switching setup.
If you actually need a backstop that doesn't depend on PM2 catching up before its next tick, that's MemoryMax - a cgroup (control group, the Linux kernel mechanism for capping resources for a group of processes) limit set directly in a systemd unit file. The difference is fundamental: PM2 learns about an overage on its next scheduled check, while the kernel stops the process via cgroup the moment it tries to allocate memory past the cap - no polling interval involved at all.
That's confirmed with an actual kill, not documentation. The test app ran directly through systemd, no PM2 involved, with a unit like this:
[Service]
Type=simple
ExecStart=/usr/bin/node app.js
Restart=on-failure
RestartSec=2
MemoryMax=100M
MemoryAccounting=yes
Once the process's retained memory crossed 100MB, systemctl status showed Active: failed (Result: oom-kill) and Main PID ... code=killed, signal=KILL. The kernel log (journalctl -k) recorded the exact cause:
Memory cgroup out of memory: Killed process 356408 (node) total-vm:1106424kB, anon-rss:101224kB, file-rss:41728kB, shmem-rss:0kB, UID:0 pgtables:808kB oom_score_adj:0
MemoryMax=100M - the hard ceiling for this service's cgroup; going over it means the kernel itself picks and kills a process inside that group (oom_memcg in the log points at the service's own unit, not the whole system).MemoryAccounting=yes - turns on memory accounting for this unit's cgroup. On modern systemd (232+, so any current Ubuntu/Debian) accounting is already on by default, making the line redundant on such a host - but it's harmless to spell out, and it's a real safeguard on older systems where the default may differ.Restart=on-failure and RestartSec=2 - without this pair, a process killed by the OOM killer just sits in failed state forever. With them - verified live - systemd brought the process back exactly 2 seconds after the kill, with a new PID.There's a real cost worth stating plainly: this is a SIGKILL - no chance for a graceful shutdown, no time to close connections or finish an in-flight request. It's a last-resort backstop protecting the whole server from one runaway process, not a routine memory-management mechanism. Set MemoryMax comfortably above your process's normal working footprint - as insurance against a catastrophe, not as a working limit meant to trigger regularly. If it fires often, that's the same signal max_memory_restart firing often would give you: there's a real leak in the code, and the place to deal with that is the leak-diagnosis article, not another layer of limits.
ps -o rss,cmd -p <PID> against what node --inspect and Chrome DevTools report for the heap alone - those are two different numbers.pm2 --version - and whether your check interval still matches the 30-second default, unless you've explicitly overridden PM2_WORKER_INTERVAL.max_memory_restart only catches RSS growth, not a blocked event loop or a request hung without a timeout.MemoryMax on the service PM2 runs under, or on its whole cgroup.Usually two reasons at once. PM2 doesn't watch memory continuously - it checks every 30 seconds by default (the WORKER_INTERVAL constant, verified on version 7.0.4), so a spike that rises and falls between two checks is simply never seen - a live test confirmed a 100MB, 2-second spike went completely unnoticed. And the limit is compared against RSS, the process's total footprint, not the V8 heap - so if your mental model comes from --max-old-space-size, expectation and reality easily diverge.
RSS (resident set size) - the process's entire physical memory footprint: the whole V8 heap, buffers allocated outside the heap, native module memory, thread stacks. PM2 gets this number through the npm package pidusage, which reads /proc/<PID>/status on Linux - confirmed by reading PM2's source. That's a different number from the V8 heap that --max-old-space-size caps.
Every 30 seconds by default - confirmed in PM2 7.0.4's source (constants.js, WORKER_INTERVAL: process.env.PM2_WORKER_INTERVAL || 30000). Overridable via that same environment variable when the PM2 daemon starts. Between two checks, PM2 has no visibility into the process's memory at all.
No - it doesn't locate or fix the leak, it just kills the process once RSS crosses the threshold. If the leak is real, the limit keeps firing on a predictable interval instead of an unpredictable one, and every one of those restarts in fork mode goes through the same stop-then-start cycle as a plain pm2 restart, with the same gap in service.
MemoryMax is a hard cgroup memory ceiling set in a systemd unit file. Unlike PM2, which learns about an overage on its next scheduled check, the Linux kernel stops the process via cgroup the moment it tries to allocate past the limit - no timer involved. Verified live: a process with MemoryMax=100M was killed by the cgroup OOM killer immediately on trying to allocate past the cap, confirmed both by systemctl status and the exact kernel log line.
max_memory_restart measures RSS - the process's total physical footprint - not the V8 heap that --max-old-space-size caps. The number comes from the npm package pidusage, which reads /proc/<PID>/status.pm2 restart - a full stop and start, the same second of downtime already measured in the zero-downtime deploy article. In cluster mode, workers swap one at a time - the same mechanism pm2 reload uses.MemoryMax is a genuinely hard limit with no polling delay, confirmed with a live cgroup OOM kill. It's an emergency backstop, not routine memory management: it kills the process with SIGKILL and no chance for graceful shutdown, so set it well above your process's working footprint - not as a substitute for actually finding the leak.This article closes the series on running Next.js reliably on a small VPS: from diagnosing the leak itself, through an external healthcheck, a perimeter with nginx and ufw, a zero-downtime deploy, and finally an honest look at what max_memory_restart does and doesn't do. The whole path in order: