Your Container Restarted. There's No Error in the Logs.
You check kubectl describe pod or docker logs and the application just stops. No stack trace. No FATAL ERROR: Ineffective mark-compacts near heap limit. Just a dead process and an exit code that the Node.js docs never tie to a memory failure.
This is the moment most engineers waste an hour. They assume it's a memory leak in the app, bump --max-old-space-size to some bigger number, redeploy, and wait for it to happen again. Sometimes that fix works by accident. Most of the time it doesn't, because the problem was never inside V8's heap in the first place.
There are two completely different failure modes hiding behind "the Node process died from memory," and they need different fixes. Mixing them up is the single most common mistake in Node.js memory troubleshooting.
The Two Failure Modes, Explained Properly
V8 heap exhaustion happens entirely inside the Node.js process. V8 has a configured ceiling for the old-space heap (--max-old-space-size, defaulting to a value based on available memory in recent Node versions). When allocations push past that ceiling and garbage collection can't free enough space, V8 gives up. It prints FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory along with a native stack trace, then aborts the process. The abort raises SIGABRT, so the exit code is 134 (128 + 6) on Linux, and you will often see Aborted (core dumped). This is V8 policing itself.
Container OOM kill happens completely outside V8. The Linux kernel's cgroup controller is watching total memory used by every process in that cgroup: RSS, page cache charged to the cgroup, native addons, Buffer allocations outside the V8 heap, worker threads, everything. When the cgroup crosses memory.max (or memory.limit_in_bytes on cgroup v1), the kernel's OOM killer sends SIGKILL to a process in that group (usually the one using the most memory). There is no warning, no stack trace, no chance for V8 to log anything, because the process doesn't get to run any more JavaScript. It just stops existing. The container exits with code 137, which is 128 + 9 (SIGKILL).
That distinction, V8 deciding it's out of room versus the kernel deciding the whole cgroup is out of room, is the entire diagnosis. Everything else is detail.
Red Herrings to Avoid First
Before you touch any memory flag, rule out the things that look like memory problems but aren't:
- A crashing dependency (native module segfault) can also produce a silent exit with no log, similar to CrashLoopBackOff with no logs available.
- Liveness probe failures in Kubernetes restart pods for reasons unrelated to memory. The kubelet stops the container, and a container that doesn't exit in time gets SIGKILL, which can also surface as 137. Check probe configuration before blaming the heap.
- An exit code of 0 is a different animal entirely. That's a clean shutdown, not a crash, and deserves its own investigation, not a memory one.
If you've confirmed the process is actually dying unexpectedly, and resource usage was climbing beforehand, you're in the right territory. Now you need the decision tree.
The Decision Tree
Step 1: Check the exit code first
This is the fastest signal and most people skip it.
- Exit code 134 (SIGABRT), with a
FATAL ERRORline in stdout/stderr mentioningJavaScript heap out of memoryorIneffective mark-compacts→ this is V8's own heap limit. The process was alive long enough to log its own death. (An uncaught exception exits with code 1, so a plain 1 usually means something else.) - Exit code 137 → the process was killed by SIGKILL (137 = 128 + 9). No app-level log will exist because the process never got the chance to write one. This is the signature of a cgroup OOM kill, but 137 alone is not proof: a failed liveness probe,
docker killor a manualkill -9produce the same code. Confirm it in the next step. - Exit code 139 → SIGSEGV, a native crash, not a memory-limit issue at all. Different investigation entirely.
In Kubernetes, run:
kubectl describe pod <pod-name>
and look at the Last State section. If you see Reason: OOMKilled directly in the pod status, Kubernetes is telling you the kernel killed it. This is the cgroup limit, not V8's heap flag, regardless of what --max-old-space-size was set to.
On plain Docker, the quickest confirmation is:
docker inspect --format '{{.State.OOMKilled}}' <container>
Step 2: If it's 137, confirm with kernel logs
Don't trust the exit code alone on bare Docker hosts. Go to the source.
dmesg -T | grep -i -A 5 'oom-kill\|killed process'
You're looking for a line like Memory cgroup out of memory: Killed process <pid> (node) total-vm:.... This confirms the kernel's OOM killer fired, and it usually prints the rss figure at time of death, which tells you how much actual memory the process was using versus the limit it was killed against.
On Kubernetes, the application logs of the previous container are still available with kubectl logs --previous <pod-name>, but the kernel's OOM message is recorded on the node, not in the pod, so you need to check the node's journal:
journalctl -k | grep -i oom
Step 3: Compare cgroup memory.max against Node's heap flags
This is the step almost everyone gets backwards. --max-old-space-size constrains the V8 old-space heap only. It does nothing to constrain:
- Buffers and ArrayBuffers (off-heap)
- Native addon memory
- Worker thread heaps (each worker has its own limits)
- The young generation, which is sized separately (
--max-semi-space-size) - The process's RSS from file handles, sockets, TLS buffers
- Memory used by the runtime itself outside the JavaScript heap
Check the actual cgroup limit:
cat /sys/fs/cgroup/memory.max
(or /sys/fs/cgroup/memory/memory.limit_in_bytes on cgroup v1 hosts).
If that number is lower than what V8's heap alone could grow to, you will get OOMKilled before V8 ever hits its own ceiling, and the heap flag becomes irrelevant because the kernel kills the process first. This is exactly why raising --max-old-space-size on a container that's actually hitting the cgroup limit doesn't solve anything and can make the kill happen sooner: you just gave V8 permission to grow closer to the wall that kills everything, with less room to spare.
Step 4: Match the pattern over time
Pull up whatever time-series metric you have for container memory (cAdvisor, docker stats, Kubernetes metrics server) and overlay it against when the kill happened.
- A slow, steady climb over hours or days that flattens right before death usually points to a genuine memory leak in application code: unreleased event listeners, growing caches, unbounded arrays.
- A sharp spike correlated with a specific request (large file upload, big JSON payload, an unbounded
JSON.parseon attacker-controlled input) points to a single bad request pushing memory up instantly. Which limit it hits first decides the symptom: if it hits V8's heap ceiling you get exit 134 with a FATAL ERROR, and if it pushes RSS past the cgroup limit first you get an OOM kill (137).
Fixing Each One Correctly
For genuine V8 heap exhaustion (exit 134, FATAL ERROR logged):
- Raise
--max-old-space-sizeonly if the cgroup limit has headroom above it. - Better: find and fix the leak. Check for growing closures, uncollected timers, and caches without eviction.
- Add heap snapshots in staging under load to catch growth before production.
For container OOM kill (exit 137, kernel log confirms OOM-kill, no app error):
- Raise the container's memory limit/request, or lower
--max-old-space-sizeto leave headroom for off-heap memory. - As a starting point, set
--max-old-space-sizeto roughly 70-75% of the container's memory limit, not 100%, to leave room for non-heap allocations, then adjust based on measured RSS. Very small containers (under about 512MB) usually need an even larger margin. - Check for off-heap culprits: large Buffer usage, native module leaks, or too many worker threads each claiming their own heap.
- If running multiple Node processes (cluster mode, PM2) inside one container, remember the cgroup limit applies to the sum of all of them, not each individually. The OOM killer may also kill a child process while the container keeps running, leaving the app broken without a restart.
What Should Have Caught This Earlier
If you only alert on application-level metrics, you'll miss container OOM kills entirely, because the app never gets to report anything before it dies. You need:
- Container-level memory usage alerts (RSS approaching cgroup limit), not just V8 heap usage from
process.memoryUsage(). - An alert on OOMKilled restart reasons specifically, separate from generic pod-restart counts.
- A dashboard that shows RSS versus cgroup
memory.maxside by side with V8 heap used versus--max-old-space-size, so the gap between the two is visible before it becomes zero.
Getting this pairing right is the difference between a five-minute diagnosis and an afternoon of guessing at flags that were never the actual problem.