Here’s a situation worth being able to explain. A Node.js container gets terminated for exceeding its memory allocation, and the heap graph shows V8 sitting comfortably below the limit that was configured for exactly this reason. Nothing leaked. The configuration wasn’t ignored.

The explanation is that “memory limit” refers to three quite different things in this stack, and only one of them had been set. So this article separates the three, follows the consequences into worker counts, reloads and probes, and finishes with a sizing procedure that gives you a defensible number rather than a remembered one.

I’ll give orders of magnitude where they help. None of them is a deployment prescription.

Table of Contents

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

That first row is the one causing the confusion in the opening, and it needs two terms unpacked.

V8 divides its heap into a new space for freshly allocated short-lived objects and an old space for objects that have survived a few collections. --max-old-space-size caps the second of those, which is where long-lived data accumulates. So it’s the right dial for “how much may this process retain”, and it says nothing whatsoever about the rest.

The resident set, or RSS as the operating system reports it, is the total physical memory the process currently occupies. The V8 heap is one part of that. 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 than that, and the container limit is enforced against the resident set of everything inside the container. Which is precisely how the kernel comes to terminate a container whose heap graph looks perfectly fine.

The second row has a timing property worth knowing about: 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, in 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 you two things: more than one CPU core actually gets used, and one worker can reload while the others carry on serving traffic.

flowchart TB IN["Container ingress"] --> PM["Process manager"] subgraph C["One container memory budget"] PM --> WA["Worker A"] PM --> WB["Worker B"] WA --> HA["Own heap, own module state,<br/>own in-memory caches"] WB --> HB["Own heap, own module state,<br/>own in-memory caches"] end WA --> SH["Shared external cache"] WB --> SH

The consequence that catches people out is the one this series has now hit from three separate directions: workers share nothing in memory. A cache in a module-level variable exists once per worker, so its hit rate is worse than you expected and its total memory cost is multiplied by the worker count. Part 3 raised this about operation caches and Part 6 about module state, and here it turns up as a budgeting fact: per-worker caches are a per-worker memory line item.

Memory-based reloads are genuinely useful, and they are not “zero downtime” all by themselves. For a reload to be invisible, the replacement has to become ready before the old worker stops, in-flight requests have to drain within a coordinated shutdown deadline, long-lived connections need handling of their own, and, here’s the part that connects back to budgeting, there has to be enough memory for both workers to exist at the same time. In a container with no headroom for that overlap, a reload triggered by memory pressure briefly needs more memory than the situation which triggered it. So the measure meant to prevent a termination is the thing that causes one.

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 as incidental base-image details.

A Container Budget That Adds Up

Here’s a mental model that works:

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

That’s a budgeting model rather than a predictor. To use it at all you need measurements of resident set and container working set, not just V8 heap usage, because heap growth on its own can’t tell you how close the container is to its limit. Which is exactly the gap in the opening scenario.

Two failure directions are worth naming here. Dividing all your container memory equally into worker heap caps leaves nothing at all for non-heap allocations or reload overlap, and that produces precisely the termination this article started with. And setting the heaps too small gives you the opposite problem: garbage collection runs constantly, CPU goes into collection instead of rendering, and tail latency degrades while your memory graphs look admirably flat.

The right order of magnitude for an SSR worker is typically hundreds of megabytes up to low gigabytes, driven by component-tree size, response sizes and concurrency. Treat that range as context rather than a recommendation. The procedure at the end of the article is how you get your own number.

What Server Rendering Actually Retains

Understanding what actually holds memory is what makes the measurements interpretable.

Per-request state, held across awaits. An SSR render fetches data, awaits a subgraph, and in the meantime keeps the partially built component tree, the fetched data and the request context all alive. Concurrency then 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 thing as requests per second.

Serialized payloads. Everything transferred to the browser, so the state that made Parts 11 and 12 work, gets built in memory before it’s written out. A page that serializes a large content tree pays for it twice over, once in the objects and once in the serialization.

Things that outlive requests. Per-worker caches, compiled code and the module graph. These form the baseline that all the per-request costs sit on top of.

Telling Memory Apart From CPU and Waiting

SSR mixes genuine CPU work, so rendering trees, parsing large GraphQL responses and serializing payloads, together with waiting around on dependencies. Attribute a slowdown to the wrong one of those and your fix is wasted.

flowchart LR S["Slow or failing responses"] --> E["Look at four signals together"] E --> A["Per-worker CPU and<br/>event-loop delay"] E --> B["Dependency latency<br/>from spans"] E --> C["Resident set, heap,<br/>GC activity"] E --> D["Tail latency and<br/>restart history"] A --> X["Choose one targeted experiment"] B --> X C --> X D --> X

Two inferences to steer clear of. Low average CPU does not prove an application is I/O-bound. A single worker can saturate its own event loop while the container average looks entirely relaxed, because that average includes the idle workers and all the other processes. The signal which exposes it is event-loop delay: schedule a timer for a short interval, measure how much later it actually fires, and the difference is how long synchronous work made everything else wait. A delay in the tens or hundreds of milliseconds means requests are queued up behind CPU work, whatever your average CPU chart has to say about it. And it gets measured per worker, which is the level at which the problem actually exists. The second one: a rising post-collection heap baseline is not proof of a leak. A healthy heap graph is a sawtooth, where usage climbs as requests allocate and then drops when a major collection runs. The bottom of each tooth, meaning what’s still held after a collection, is the baseline, and it’s the only part of the graph worth watching for retention, because the peaks are mostly garbage that hasn’t been collected yet. A baseline trending upward is the signature of a leak, but it isn’t proof of one: caches filling up to their configured size, startup work completing, and a changed traffic mix all raise it and then level off. So it’s a reason to compare heap snapshots and look at what’s retaining objects, which is a rather different statement from a conclusion.

Probes Have Three Different Jobs

Orchestrators ask three quite distinct questions, and answering all three with one endpoint is a common source of self-inflicted outages:

  • Startup. Has initialization finished? Until it has, the other checks shouldn’t be applied at all.
  • Readiness. Should this replica receive new traffic right now? A negative answer removes traffic and changes nothing else.
  • Liveness. Is the process so thoroughly stuck that restarting it is likely to help? A negative answer destroys in-flight work.

Conflate readiness and liveness and a replica which is merely busy gets restarted, which turns a transient capacity problem into a permanent one. And do note that a detailed diagnostics endpoint for operators is not the endpoint your orchestrator should be polling: it’s more expensive, it exposes more, and per Part 15 it needs access control that the probe path doesn’t.

The Restart Cascade

The failure mode all those distinctions exist to prevent looks like this:

flowchart TB L["More concurrent renders"] --> P["Memory pressure and GC work"] P --> D["Event-loop delay"] D --> PR["Probe responses arrive late"] PR --> RD["Readiness removes this replica"] PR --> LV["Repeated liveness failures restart it"] RD --> RM["Remaining replicas take more traffic"] LV --> RM RM --> P

Every single step in that chain 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, because the restarts are the last link rather than the cause.

Relaxing your liveness thresholds is the correct immediate mitigation, and it is not a fix. The cause is insufficient capacity or excessive retention, and a probe that no longer notices is a probe that won’t warn you next time either.

Replicas and Scale-Out

Autoscaling reacts after demand has already changed, and then a new replica needs to start up, connect to its dependencies and warm its caches. Which, given the per-worker caches above, means it’s slower than its peers for a while. The existing replicas absorb everything in between.

So your minimum capacity should come from a representative sustained workload plus realistic bursts, with headroom for a rolling release and for one unhealthy replica. And be specific about the scaling trigger too, because scaling on HTTP concurrency and scaling on memory usage respond to quite different situations, and which one you want depends on which resource runs out first. Which, once 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 two different dials. Reducing replicas does not necessarily reduce reserved infrastructure.

A Repeatable Sizing Experiment

What comes out of this procedure is a number you can defend, plus the evidence backing it.

  1. Establish a baseline with generous headroom, using a fixed workload and a comparable cache state. Comparable state really matters here, because a warm run and a cold run are two different experiments.
  2. Change one variable: worker count, heap budget, or container allocation. One of them.
  3. 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.
  4. Exercise a worker reload and a replica replacement under load. This is the step that finds missing overlap headroom, and it’s the step most often skipped, because it means deliberately breaking something.
  5. Run a longer soak to separate startup growth and cache filling from genuine retention. An hour is frequently enough to invert a conclusion you drew from five minutes.
  6. Recheck after upgrades. A framework or dependency change can shift the baseline, and your previous number quietly becomes an assumption.

Report the shape of the result, so latency stayed flat, memory plateaued, the restarts stopped, rather than publishing an allocation as though it were a universal minimum. Somebody else’s number describes somebody else’s component tree.

The Opening Restart Explained

So the container was terminated because its memory allocation had to cover the resident sets of several workers, plus native allocations, plus the reload that memory pressure had just triggered, while the only limit anybody had actually set governed one part of one worker. The heap graph was perfectly accurate and completely irrelevant.

Here’s the rule I’d take away from that. Before you change a memory setting, say which of the three limits you’re 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, I suspect, the most common memory intervention in Node.js deployments, and it achieves precisely nothing.

Further Reading

What’s Next


Munir Husseini is a software architect specializing in full-stack TypeScript, .NET, and cloud-native architectures.