Memory, Stability, and PM2 — Why a Heap Limit Did Not Prevent the Restart
Hero image credit: Photo by Francesco Ungaro on Pexels
Part 17 of the Nuxt and .NET series.
Here is a situation worth being able to explain. A Node.js container is terminated for exceeding its memory allocation, and the heap graph shows V8 comfortably below the limit that was configured for exactly this reason. Nothing leaked. The configuration was not ignored.
The explanation is that “memory limit” refers to three different things in this stack, and only one of them was set. This article separates them, follows the consequences into worker counts, reloads and probes, and ends with a sizing procedure that produces a defensible number instead of a remembered one.
Orders of magnitude appear where they help; none of them is a deployment prescription.
Table of Contents
- Three Limits Doing Three Different Jobs
- Several Workers Sharing One Budget
- A Container Budget That Adds Up
- What Server Rendering Actually Retains
- Telling Memory Apart From CPU and Waiting
- Probes Have Three Different Jobs
- The Restart Cascade
- Replicas and Scale-Out
- A Repeatable Sizing Experiment
- The Opening Restart Explained
- Further Reading
- What’s Next
Three Limits Doing Three Different Jobs
| Control | What it governs | What it does not guarantee |
|---|---|---|
V8 --max-old-space-size |
The old-generation heap budget of one Node.js process | Any cap on that process’s total memory |
PM2 max_memory_restart |
A process-memory threshold that triggers a reload | An immediate reaction to every spike |
| Container memory allocation | The budget shared by every process in the container | Room for old and new workers to overlap during a reload |
The first row is the one that causes the confusion in the opening. The V8 heap is a part of a process’s resident set, not the whole of it. Also resident: native allocations made by libraries, Buffer contents, thread stacks, compiled code, and the runtime’s own overhead. A process can sit at 60% of its configured heap limit and still have a resident set far larger, and the container limit is enforced against the resident set of everything inside it.
The second row has a timing property worth knowing: PM2’s memory checks are periodic. A process can cross the threshold and be terminated by the kernel — or hit V8’s own heap ceiling — between two checks. The threshold is a mitigation, not a guarantee.
Several Workers Sharing One Budget
Running a small pool of workers under a process manager buys two things: more than one CPU core gets used, and one worker can reload while others serve traffic.
The consequence that catches people is the one this series has now hit from three directions: workers share nothing in memory. A cache in a module-level variable exists once per worker, so its hit rate is worse than expected and its total memory cost is multiplied by the worker count. Part 3 raised this about operation caches, Part 6 about module state, and here it appears as a budgeting fact: per-worker caches are a per-worker memory line item.
Memory-based reloads are genuinely useful and are not “zero downtime” by themselves. For a reload to be invisible, the replacement must become ready before the old worker stops, in-flight requests must drain within a coordinated shutdown deadline, long-lived connections need their own handling, and — the part that connects back to budgeting — there must be enough memory for both workers to exist at once. A reload triggered by memory pressure, in a container with no headroom for overlap, is how a mitigation becomes the cause.
Keep the worker count and heap limits configurable rather than baked into an image, and treat the Node.js and process-manager versions as controlled dependencies rather than incidental base-image details.
A Container Budget That Adds Up
A workable mental model:
container memory
> steady-state resident memory of all workers
+ process manager and native overhead
+ reload overlap (one extra worker's worth)
+ safety margin for workload variation
This is a budgeting model, not a predictor. To use it you need measurements of resident set and container working set, not only V8 heap usage — heap growth alone cannot tell you how close the container is to its limit, which is precisely the gap in the opening scenario.
Two failure directions are worth naming. Dividing all container memory equally into worker heap caps leaves nothing for non-heap allocations or reload overlap, which produces exactly the termination this article started with. And setting heaps too small produces the opposite problem: garbage collection runs constantly, CPU goes into collection rather than rendering, and tail latency degrades while memory graphs look admirably flat.
The right order of magnitude for an SSR worker is typically hundreds of megabytes to low gigabytes, driven by component-tree size, response sizes and concurrency. That range is context, not a recommendation; the procedure at the end of the article is how you get your number.
What Server Rendering Actually Retains
Understanding what holds memory makes the measurements interpretable.
Per-request state, held across awaits. An SSR render fetches data, awaits a subgraph, and meanwhile keeps the partially built component tree, the fetched data and the request context alive. Concurrency multiplies that: ten simultaneous renders hold ten copies, and if a dependency is slow, they hold them for longer. This is why memory demand tracks concurrent renders in flight, which is not the same as requests per second.
Serialized payloads. Everything transferred to the browser — the state that made Parts 11 and 12 work — is built in memory before it is written. A page that serializes a large content tree pays for it twice, in the objects and in the serialization.
Things that outlive requests. Per-worker caches, compiled code and the module graph. These are the baseline the per-request costs sit on top of.
Telling Memory Apart From CPU and Waiting
SSR mixes real CPU work — rendering trees, parsing large GraphQL responses, serializing payloads — with waiting on dependencies. Attributing a slowdown to the wrong one wastes the fix.
Two inferences to avoid. Low average CPU does not prove an application is I/O-bound. A single worker can saturate its event loop while the container average looks relaxed, because the average includes idle workers and the other processes; per-worker CPU and event-loop delay are what show that. And a rising post-collection heap baseline is not proof of a leak. Caches filling, startup work completing and a changed traffic mix all raise the baseline. It is a reason to look at retained objects, which is a different statement from a conclusion.
Probes Have Three Different Jobs
Orchestrators ask three distinct questions, and answering them with one endpoint is a common source of self-inflicted outages:
- Startup — has initialization finished? Until it has, other checks should not be applied.
- Readiness — should this replica receive new traffic right now? A negative answer removes traffic and changes nothing else.
- Liveness — is the process so stuck that restarting it is likely to help? A negative answer destroys in-flight work.
Conflating readiness and liveness means that a replica which is merely busy gets restarted, turning a transient capacity problem into a permanent one. And note that a detailed diagnostics endpoint for operators is not the endpoint the orchestrator should poll: it is more expensive, it exposes more, and per Part 15 it needs access control the probe path does not.
The Restart Cascade
The failure mode these distinctions prevent looks like this:
Every step is individually reasonable, and together they amplify a load increase into a shrinking pool. Diagnose it from probe history alongside memory signals and request timing rather than from the restart count alone — the restarts are the last link, not the cause.
Relaxing liveness thresholds is the correct immediate mitigation and is not a fix. The cause is insufficient capacity or excessive retention, and a probe that no longer notices is a probe that will not warn you next time.
Replicas and Scale-Out
Autoscaling reacts after demand has already changed, and a new replica then needs to start, connect to dependencies and warm its caches — which, with the per-worker caches above, means it is slower than its peers for a while. The existing replicas absorb everything in between.
So minimum capacity should come from a representative sustained workload plus realistic bursts, with headroom for a rolling release and for one unhealthy replica. Be specific about the scaling trigger too: scaling on HTTP concurrency and scaling on memory usage respond to different situations, and the one you want depends on which resource runs out first — which, again, is what the experiment below tells you.
One distinction worth keeping separate in cost conversations: application replica count and the underlying node capacity are different dials. Reducing replicas does not necessarily reduce reserved infrastructure.
A Repeatable Sizing Experiment
The output of this procedure is a number you can defend, plus the evidence for it.
- Establish a baseline with generous headroom, using a fixed workload and comparable cache state. Comparable state matters — a warm run and a cold run are different experiments.
- Change one variable: worker count, heap budget, or container allocation. One.
- Record the full picture: delivered throughput, tail latency, errors, resident set per worker, container working set, GC activity, and restarts. Throughput without tail latency and restarts is not a result.
- Exercise a worker reload and a replica replacement under load. This is the step that finds missing overlap headroom, and it is the step most often skipped because it requires deliberately breaking something.
- Run a longer soak to separate startup growth and cache filling from genuine retention. An hour is often enough to invert a conclusion drawn from five minutes.
- Recheck after upgrades. A framework or dependency change can move the baseline, and the previous number silently becomes an assumption.
Report the shape of the result — latency stayed flat, memory plateaued, restarts stopped — rather than publishing an allocation as a universal minimum. Somebody else’s number describes somebody else’s component tree.
The Opening Restart Explained
The container was terminated because its memory allocation covered the resident sets of several workers plus native allocations plus the reload that memory pressure had just triggered, while the only limit anyone had set governed one part of one worker. The heap graph was accurate and irrelevant.
The rule I would take from this: before changing a memory setting, say which of the three limits you are changing and which measurement told you it was the binding one. --max-old-space-size answers “how much heap may this process use,” a restart threshold answers “when should we recycle a process,” and the container allocation answers “how much may all of this cost together.” Tuning the first because the third was exceeded is the most common memory intervention in Node.js deployments, and it does nothing.
Further Reading
What’s Next
- Part 18: Tracing, Logging, and Process Diagnostics — Getting the evidence the experiment above depends on.
- Part 19: Load Testing an SSR Migration — Generating the workload, and reporting the result without overclaiming.
Munir Husseini is a software architect specializing in full-stack TypeScript, .NET, and cloud-native architectures.
Category: Advanced Web App With Nuxt And Net