Repository navigation
Description
Bug Description
A long-running Node.js 26 process that makes outbound requests with global fetch was OOM-killed by the kernel. The bundled undici negotiates HTTP/2 by default. The process grew from ~0.8 GB to over 3 GB in under a minute, inside node::http2::Http2Session::CopyDataIntoOutgoing: one HTTP/2 client session's outgoing write queue grew without bound. The session's maxSessionMemory (10 MB default) did not stop it.
This looks related to #6149 (HTTP/2 session-memory handling in undici 8), but the failure is worse: the process crashed instead of requests failing. I'm filing it separately so the evidence is in one place. Happy to fold it into #6149 if you prefer.
Reproduction
Production failure: no reproduction of the OOM itself. It happened once, in production, after ~6 months of running Node 26 with HTTP/2 negotiated by default on this traffic. The evidence under "Actual Behavior" is from the kernel OOM report and the exact node binary.
Standalone reproduction of the same failure mode (client HTTP/2 queue growing against a peer that stops draining). It does not reproduce the kernel OOM or a crash inside Http2Session::CopyDataIntoOutgoing. Tested on node-v26.12.1-linux-x64 (the binary in this report) and node-v26.13.0-linux-x64 (latest v26 release, 2026-10-28). process.versions.undici is 8.12.2 on both. Neither build contains the session-memory fixes merged to Node main on 2026-11-04 (nodejs/node#67113, nodejs/node#67114). Node 22, Node 24 and v27 alpha were not tested.
An HTTP/2 peer accepts the handshake, then pauses the socket. The client uses http2.connect with maxSessionMemory: 1 and submits 30,000 GETs:
import http2 from 'node:http2'
const server = http2.createServer()
server.on('connection', (socket) => setTimeout(() => socket.pause(), 30))
server.on('stream', (stream) => stream.on('error', () => {}))
server.listen(0, '127.0.0.1', () => {
const session = http2.connect(`http://127.0.0.1:${server.address().port}`, {
maxSessionMemory: 1 // documented default is 10 MB; 1 MB fails the same way
})
session.on('connect', () => {
let firstError = null
for (let i = 0; i < 30_000; i++) {
const stream = session.request({ ':path': `/${i}`, ':method': 'GET' })
stream.on('error', (err) => {
firstError ??= `${err.code}: ${err.message}`
})
stream.end()
}
console.log(JSON.stringify({
maxSessionMemoryMB: 1,
submitted: 30_000,
outboundQueueSize: session.state.outboundQueueSize,
rssMB: Math.round(process.memoryUsage().rss / 1048576),
firstError
}))
process.exit(0)
})
})Output (Linux x64, the process exits right after the log line):
v26.12.1: {"maxSessionMemoryMB":1,"submitted":30000,"outboundQueueSize":30001,"rssMB":245,"firstError":null}
v26.13.0: {"maxSessionMemoryMB":1,"submitted":30000,"outboundQueueSize":30001,"rssMB":244,"firstError":null}
A second run in a Docker container (node:26.12.1-slim and node:26.13.0-slim) gave the same outboundQueueSize and firstError with rssMB 284 and 285, so the RSS values vary by host.
Observations from a longer harness (pause the server socket, submit GETs, yield every 25,000, then resume):
- The default 10 MB cap behaves the same while the peer is not reading. At 100,000 GETs on v26.13.0,
session.socket.bytesWrittenhad stopped at 462,517,session.state.outboundQueueSizewas 75,000, RSS was 545 MB, and no stream had errored. v26.12.1 matched (RSS 541 MB, same frozenbytesWritten,outboundQueueSize75,000, zero errors). The pause was not perfect (the server still read some bytes, because HTTP/2 calls read-start directly), butbytesWrittenfroze, which is the "socket stopped draining" observation. maxSessionMemoryfires only after the peer resumes. Resuming the peer fails the queued streams and closes the session. On both binaries the first error wasERR_HTTP2_STREAM_ERROR: Stream closed with error code NGHTTP2_ENHANCE_YOUR_CALM. In that runoutboundQueueSizewas 30,000 and RSS was 249 MB (v26.13.0) / 248 MB (v26.12.1) before the resume, and 298 MB after the session closed. During the stall,firstErrorstayed null.- One request body can queue past the cap on a single stream. With
maxSessionMemory: 1and the peer not reading, 5,000,000 writes of one 256-byte chunk on v26.13.0 leftbytesWrittenat 65,647,writableLengthat 1,279,999,744, RSS at 434 MB (heap 354 MB) and zero errors.outboundQueueSizestayed 0 andremoteWindowSizestayed 33,024: the bytes stayed on the stream, waiting for flow control. The test reused oneBuffer.alloc(256)and ignoredwrite()returning false, sowritableLengthis the sum of chunk lengths, not 1.28 GB of copied payload (RSS did not grow by that much because the chunks alias one buffer). The queue of write records did grow.
What this does not show:
- Global
fetchwith the default dispatcher. Against a local TLS server that negotiatedh2and then paused, 1,000 parallelfetchrequests opened 10 sessions. Eachhttp2.connectfrom undici carriedpeerMaxConcurrentStreams: 100and did not passmaxSessionMemory. RSS stayed about 112 MB and the fetches were still pending after 2 seconds, so undici's default dispatcher does not submit the unbounded frame pile this reproduction creates. - The production crash. No run died inside
node::http2::Http2Session::CopyDataIntoOutgoing; the test process was stopped at a few hundred MB, and no native stack was captured. Growth in this reproduction tracks how many client frames were submitted, so a few GETs per minute on one long-livedfetchsession is still unexplained.
From reading Node v26.13.0 (not from a runtime trace): maxSessionMemory is checked when the peer opens a stream (Http2Session::CanAddStream, called from OnBeginHeadersCallback). ClientHttp2Session.request() goes through requestOnConnect to Http2Session::SubmitRequest, which calls nghttp2_submit_request and Http2Stream::New without CanAddStream. Http2Stream::DoWrite calls IncrementAvailableOutboundLength and does not check has_available_session_memory. SendPendingData copies every chunk that nghttp2_session_mem_send returns into outgoing_buffers_ through CopyDataIntoOutgoing, with no cap inside that loop. current_session_memory() counts outgoing_storage_.size() and does not count the outgoing_buffers_ descriptor vector (each NgHttp2StreamWrite is 32 bytes).
Production traffic shape:
- Global
fetch, default dispatcher. All requests to one HTTPS origin (behind a CDN that negotiatesh2over ALPN) are multiplexed on one long-lived session. - Mostly small
GETs, several per minute per process. Process uptime ~5 days.
Expected Behavior
A client HTTP/2 session whose socket stops draining should either error or close, or queue up to maxSessionMemory and fail requests. It should not grow until the process is killed.
Actual Behavior
- Kernel memcg OOM report (allocating thread
node-MainThread):- cgroup usage
3145728kB= limit. - Memory stats:
anon 3189157888,file 0,shmem 0,sock 12288,kernel 29347840. - Killed process:
anon-rss:3114056kB. - The fault was in
do_anonymous_page, writing into freshly allocated memory.
- cgroup usage
- Faulting instruction. RIP resolves, against the exact
nodebinary's symbol table, tonode::http2::Http2Session::CopyDataIntoOutgoing(unsigned char const*, unsigned long).- The faulting bytes are a loop that copies 32-byte elements into a new buffer (
movdqu xmm0,[r15+0x10]…movups [r14+0x10],xmm0,cmp r13,r15). RDX=0x0000000080001002, and the source and destination regions are ~1–2 GiB apart.- My reading (inference):
outgoing_buffers_was being reallocated from ~1 GiB to ~2 GiB, i.e. tens of millions of queued outgoing writes.
- The faulting bytes are a loop that copies 32-byte elements into a new buffer (
- Not the JS heap.
heap_size_limitin this cgroup is 1632 MB, and no V8 "heap out of memory" fatal occurred (exit 137, SIGKILL from the kernel). The growth was native. - Queued, not sent. Container working set was ~795 MB at the last metrics scrape, 57 s before the kill, and flat at ~750–850 MB for the preceding days. Network receive and transmit stayed at baseline through the kill (~5–8 MB/min in, 2–4 MB/min out).
- Event loop. In the ~10 s before the kill it slowed: a normally ~100 ms request took ~960 ms. Nothing more was logged until the kill.
Not established: which frames were queued (HEADERS / DATA / RST_STREAM / WINDOW_UPDATE / PING), or why the socket stopped draining. No heap snapshot or NODE_DEBUG=http2 output exists from the incident.
Logs & Screenshots
node-MainThread invoked oom-killer: gfp_mask=0xcc0(GFP_KERNEL), order=0, oom_score_adj=903
RIP: 0033:<node>+0x70fbe9 -> node::http2::Http2Session::CopyDataIntoOutgoing
Code: ... f3 41 0f 6f 47 10 49 83 c7 20 49 8d 46 20 <41> 0f 11 46 10 4d 39 fd 74 1d ...
RDX: 0000000080001002
memory: usage 3145728kB, limit 3145728kB, failcnt 5091
anon 3189157888 / file 0 / sock 12288 / shmem 0
Memory cgroup out of memory: Killed process (node-MainThread) total-vm:14508236kB, anon-rss:3114056kB, file-rss:48924kB, shmem-rss:0kB
Environment
- OS: Linux x86_64, kernel 6.12 (Amazon Linux 2023 host), Ubuntu 24.04-based container, cgroup v2, 3 GiB memory limit
- Node.js version: v26.12.1
- undici version: 8.12.2 (bundled with Node, used through global
fetch; connector defaultallowH2 = true)
Additional context
- Related: H2: a session Node has reset for memory (ENHANCE_YOUR_CALM) stays in the pool and fails every later request #6149 and its open fix PRs fix(client-h2): close session when stream receives ENHANCE_YOUR_CALM #6150, fix(h2): retire a session that resets a stream for memory #6155, fix(h2): recover from ENHANCE_YOUR_CALM without aborting unrelated streams #6165; http2: release queued data memory on stream cancellation node#67113 (release queued data memory on stream cancellation), http2: exclude queued data from response memory checks node#67114 and http2: large queued request body prevents receiving an early response node#66739. As of 2026-11-08 the Node fixes are merged to
mainbut not in a v26 release. - Questions:
- Is there a known path where undici keeps writing to an HTTP/2 session (opening streams, or sending RST_STREAM / PING) while the session's socket is not draining?
- Should
maxSessionMemorybound the storage behindCopyDataIntoOutgoing, or is that a Node core issue? I'll report it there too if so. - What diagnostics would help if it recurs?
- Mitigation we're evaluating:
Agent({ allowH2: false })for outbound requests.
Activity
http2: enforce maxSessionMemory for client requests