pm2 max_memory_restart Doesn't Work the Way You Think: What Actually Limits Memory

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_restart watches RSS (resident set size) - the process's full physical memory footprint - not the V8 heap that --max-old-space-size caps. 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's MemoryMax: 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.

What max_memory_restart Actually Measures

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.

How Often PM2 Checks Memory - And What That Means for a Short Spike

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.

Fork Mode vs. Cluster Mode: Same Threshold, Different Behavior at the Moment It Fires

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.

Why pm2 max_memory_restart Doesn't Work as a Leak Fix - It Just Moves the Problem to a Predictable Schedule

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.

systemd MemoryMax: A Hard Limit That Doesn't Wait on a Timer

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.

What to Check First

  • What were you actually relying on when you set max_memory_restart - the whole process's RSS, or the V8 heap? If you assumed heap, compare your process's real footprint via ps -o rss,cmd -p <PID> against what node --inspect and Chrome DevTools report for the heap alone - those are two different numbers.
  • Which PM2 version you're actually running - pm2 --version - and whether your check interval still matches the 30-second default, unless you've explicitly overridden PM2_WORKER_INTERVAL.
  • Whether the process protected by max_memory_restart runs in fork or cluster mode, and whether you're prepared for a second of downtime in fork mode every time it fires.
  • Whether an external healthcheck sits behind the process - max_memory_restart only catches RSS growth, not a blocked event loop or a request hung without a timeout.
  • Whether there's a hard limit above max_memory_restart to protect the server itself if a process slips past PM2's check between polls - systemd's MemoryMax on the service PM2 runs under, or on its whole cgroup.

FAQ

Why does pm2 max_memory_restart not trigger?

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.

Does PM2's max_memory_restart measure RSS or heap?

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.

How often does PM2 check process memory?

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.

Does max_memory_restart fix a memory leak?

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.

What is MemoryMax in systemd, and how does it differ from max_memory_restart?

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.

Short Version

  • 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.
  • The check runs on a timer, not continuously: every 30 seconds by default (confirmed in PM2 7.0.4's source). A live test showed a 100MB, 2-second spike went completely unnoticed, while a sustained climb killed the process on the very next check - confirmed by the daemon's own log.
  • In fork mode, the limit firing goes through the same code as a plain 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.
  • The limit doesn't fix a leak on its own - it just moves the hang onto a predictable interval and repeats the same gap in service every time.
  • systemd's 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.

What's Next

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:

PUBLISHED
AUTHOR
HIP-HOSTING
LANGUAGES
EN · RU