Node.js 24.19.0 "Krypton" shipped on 3 August 2026. Five days later, nodejs/node#65110 opened with FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory, filed by an operator whose only change was the runtime version. Three weeks on the issue is still open and labelled needs more info. Node 24.20.0 arrived on 26 August with V8 backports and HTTP/2 allocation work, and nothing in its commit list references the report.
The short version: that crash line currently covers four different production failures with four different fixes, and the advice every search result gives you (raise --max-old-space-size) makes two of them harder to diagnose. Read the process exit code first, then the runtime version, then whether you terminate HTTP/2. Those three checks split the four causes apart in about a minute.
The population getting hit is specific and unlucky: teams with good patch hygiene. Node 24.18.1 was the 29 July 2026 security release covering 11 CVEs, three of them HIGH and two in HTTP/2. Everyone who patched on time landed on 24.18.1, then floated to 24.19.0 within days because their Dockerfile says node:24-trixie-slim. If your team has been running hot on patch cadence since Patch Tuesday's 206-CVE month, you are exactly the shape of shop this caught. It is the same blast radius as the native module rebuild break after npm 12: a floating base image tag moving underneath code that did not change.
Which of the four is it?
Cause 1: your own leak
Retained size grows monotonically across forced GCs, and the same load profile fails on the previous runtime too. The separating test is boring and worth doing before anything else: pin the runtime to 24.18.1 by digest, hold code and package-lock.json constant, replay identical load. A clean run there takes your code out of the frame and makes the runtime the variable.
That is what happened in nodejs/help#5165. A WebSocket load-generation service on node:24-trixie-slim started dying anywhere between two minutes and several hours into a run, with unchanged code and an unchanged lockfile. Rolling back to 24.18.1 stopped it.
Cause 2: the Http2Session leak (CVE-2026-21714)
Connection-level WINDOW_UPDATE frames on stream 0 that push the flow-control window past 2³¹−1 make the server emit GOAWAY and then never release the session (GHSA-cfr8-f5q7-84wq). Same crash line, remotely triggerable, patched on 24 March 2026 in 24.14.1, 22.22.2, 20.20.2 and 25.8.2.
Version alone rules this out in thirty seconds. It also sets a hard floor on your rollback: anything below 24.14.1 terminating HTTP/2 is a patch job, and no GC tuning touches it. If growth tracks inbound connection count and Http2Session instances pile up in a snapshot, stop reading and upgrade.
Cause 3: V8's ceiling is not the container's
This is where most triage goes wrong. Node has read cgroup limits since v12, and per Red Hat's container memory guidance the default old-space is about 50% of container memory up to 4 GiB, capping around 2 GB above that. The #5165 crash trace reports roughly 2034 MB while the reporter's APM showed about 400 MB in use. That 2034 MB is the default V8 ceiling, and it says nothing about the pod's limit.
Two commands separate this from everything else. Inside the running container, not in the Dockerfile:
node -p "require('v8').getHeapStatistics().heap_size_limit/1048576"
kubectl describe pod "$POD" | grep -A2 "Last State"
FATAL ERROR: Ineffective mark-compacts with a JavaScript stack means V8 hit its own configured limit. Exit 137 with no fatal text at all means the kernel took the process, and OOMKilled will be sitting in the pod description. The widely repeated claim that V8 ignores your container limit is false for modern Node, and believing it sends you down the wrong branch of the diagram above.
Cause 4: the regression itself, and one flag worth testing
In #65110 a Next.js app on Heroku exhausted its heap after roughly 335 seconds under load, with a mark-compact that recovered 0.8 MB out of a 1.88 GB heap. Freeing 0.8 MB of 1.88 GB means the heap is full of reachable objects at the moment of death. The sampled heap stays flat in the APM while the crash lands at the ceiling, which describes an allocation burst faster than the sampling interval. Promotion and young-generation sizing heuristics are the place to look.
There is one correlation with a designed experiment attached. The #5165 reporter runs node --enable-source-maps --optimize-for-size server.js. In V8's flag-definitions.h, optimize_for_size carries a DEFINE_VALUE_IMPLICATION that pins max_semi_space_size to 1 MB. A 1 MB young generation means a high-churn request path promotes short-lived objects straight into old space, so any change in allocation rate or object lifetime moves the promotion curve sharply. The Heroku report does not mention the flag, so treat this as a hypothesis:
node --trace-gc --optimize-for-size server.js # baseline
node --trace-gc --optimize-for-size --max-semi-space-size=16 server.js
node --trace-gc server.js # flag removed
Compare scavenge counts against promotion volume across the three. Whoever posts those numbers to #65110 probably closes it.
Why the popular fix is the wrong first move
Raising --max-old-space-size on a containerised workload trades a diagnosable failure for an undiagnosable one. V8's own limit prints a stack, can trigger a heap snapshot, and exits cleanly. The cgroup boundary kills you at exit 137 with nothing at all. Push the ceiling above roughly 75 to 80% of memory.max and you have deleted the single signal that separates a runtime regression from an application leak. Hold the ceiling where it is until you know which of the four you have.
The rollback that nobody seems to say out loud is free. 24.18.1 is the 29 July security release, so pinning back to it by digest keeps all 11 CVE fixes and gives up only the 3 August feature set: blob.textStream(), caller-supplied buffers for readFile(), TLS certificate compression. None of those are load-bearing for a service crashing in production.
The heap snapshot you are not allowed to send
This class of bug keeps dying in the tracker for a reason worth naming. Issue #60482, a reproducible memory regression between 22.20.x and 24.11.x, was closed as not planned with no root cause recorded. In #65110 a maintainer asked for a heap snapshot from a long-running instance, and the reporter's next question was how to hand one over safely.
A V8 heap snapshot is a full dump of the JavaScript heap: session tokens, auth headers, decrypted payloads, anything read from the environment. The evidence needed to fix these bugs is evidence most organisations are contractually forbidden from sharing, which is why runtime memory regressions stall while dependency CVEs get fixed in days. Build the sharing lane before you need it, the same way you keep an SBOM and VEX pipeline ready rather than assembling one during an incident.
What I read this week: nodejs/help#5165 for the WebSocket reproduction, V8's flag-definitions.h for what --optimize-for-size actually implies, and Red Hat's write-up on Node memory management in containers, which is still the clearest explanation of the default heap sizing rule.
What to run before your next deploy
- Find out who moved.
kubectl execinto every running pod today and runnode -v. Anode:24-*build produced on or after 3 August silently crossed from 24.18.1 to 24.19.0. - Read the exit code before the error text. Exit 137 with no fatal message belongs to cause 3 and is a limits problem. The
Ineffective mark-compactsline with a JS stack belongs to causes 1, 2 or 4. - Rule out the CVE by version. HTTP/2 servers below 24.14.1 / 22.22.2 / 20.20.2 need the patch, not a profiler.
- Freeze the ceiling. Leave
--max-old-space-sizealone, or set it to about 75% of the pod'smemory.maxso V8 fails first and leaves you an artifact. - Pin to 24.18.1 by digest while you investigate. Full CVE coverage, no feature loss that matters. Never pin below 24.14.1.
- Run the three
--trace-gccycles above if you pass--optimize-for-size, and post the scavenge-to-promotion numbers to nodejs/node#65110. The issue is stalled for want of exactly that data. - Give runtime bumps a cooldown. Treat the base image like any other dependency: pin by digest, wait out the first week of a minor, run it through a load lane. The same discipline you apply to locking down npm install scripts belongs on the runtime underneath them. LTS guarantees APIs. It has never guaranteed V8 GC heuristics.
FAQ
Does raising --max-old-space-size fix "Ineffective mark-compacts near heap limit"? Usually it makes diagnosis worse. Above roughly 75 to 80% of the container's memory.max, the failure moves from V8 (stack trace, optional heap snapshot, clean exit) to the kernel (exit 137, no artifact).
What is the difference between exit 137 and a JavaScript heap out of memory crash? Exit 137 with no fatal message is a cgroup OOM kill, shown as OOMKilled in kubectl describe pod. The fatal error with a JS stack is V8 hitting its own ceiling, often well inside the container limit.
Does Node respect the container memory limit? Yes, since v12. The default old-space is about 50% of container memory up to 4 GiB and caps near 2 GB above that, which is why crash traces so often report ~2034 MB no matter what the pod limit says.
Is rolling back from 24.19.0 to 24.18.1 safe? Yes. 24.18.1 is the 29 July 2026 security release covering all 11 CVEs. You lose only the 3 August feature set. Do not roll back below 24.14.1, which re-opens CVE-2026-21714.
How do I capture a heap snapshot I am allowed to share? Reproduce under synthetic load in staging with scrubbed fixtures and --heapsnapshot-near-heap-limit=1. Node 24.20.0 added Permission Model support for v8.setHeapSnapshotNearHeapLimit (PR #64808), so the trigger works in locked-down deployments.
Comments
Be the first to comment.