When a connection negotiates HTTP/2 (allowH2: true, the default since
v8.0.0), idle sessions are never reaped — keepAliveTimeout /
keepAliveMaxTimeout have no effect. The socket stays ESTABLISHED
indefinitely after the response body has been fully consumed, with the client
idle in the pool (agent.stats reports connected: 1, free: 1, running: 0, size: 0).
The h1 path destroys an idle connection after keepAliveTimeout via the
parser's TIMEOUT_KEEP_ALIVE timer (lib/dispatcher/client-h1.js). The h2
path has no equivalent: lib/dispatcher/client-h2.js only arms per-stream
headersTimeout/bodyTimeout timers (the only timer references in the file)
— keepAliveTimeout is never consulted, and there is no session-idle timer at
all.
In long-running processes that contact many distinct origins (crawlers,
scanners, webhook dispatchers), this accumulates one pinned socket + TLS/h2
session state per h2-capable origin, unbounded. Real-world data point: a
scanner of ours accumulated 3,600 pinned ESTABLISHED sockets (+1.7 GiB RSS)
in 17 hours after migrating from Node's built-in fetch (bundled undici 6, h2
off) to npm undici 8 — the migration, not any version bump within 8.x, exposed
it.
Version bisect
Same test (tuned Agent, GET, body fully consumed, h2-capable origin), socket
state checked well past keepAliveTimeout:
| undici |
idle socket reaped? |
| 7.27.2 |
✅ yes (~keepAliveTimeout) |
| 8.0.3 |
❌ never |
| 8.1.0 |
❌ never |
| 8.2.0 |
❌ never |
| 8.3.0 |
❌ never |
| 8.4.0 |
❌ never |
i.e. exactly where allowH2 flipped to default-on (v8.0.0). On 8.x with
explicit allowH2: false, reaping works correctly — including for unconsumed
bodies and HEAD. Affects both fetch() and request(), Agent and bare
Client.
Reproducible By
Self-contained (local h2 server, self-signed cert, server-side connection
tracking — no /proc or external origins):
// Repro: undici >=8 never idle-reaps HTTP/2 sessions — keepAliveTimeout has
no effect on h2.
// Run: npm i undici@8.4.0 && node undici-h2-repro.mjs
// (self-contained: local h2 server, self-signed cert, server-side connection
tracking)
import { createSecureServer } from "node:http2";
import { Agent, fetch } from "undici";
const key = `<PASTE: contents of a self-signed key.pem — see note below>`;
const cert = `<PASTE: contents of the matching cert.pem>`;
// generate with: openssl req -x509 -newkey ec -pkeyopt
ec_paramgen_curve:prime256v1 \
// -nodes -keyout key.pem -out cert.pem -subj "/CN=localhost" -days 365
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function probe(allowH2) {
const server = createSecureServer({ key, cert, allowHTTP1: true });
let open = 0;
server.on("secureConnection", (sock) => { open++; sock.on("close", () =>
open--); });
server.on("request", (req, res) => res.end("hello"));
await new Promise((r) => server.listen(0, "127.0.0.1", r));
const port = server.address().port;
const agent = new Agent({
allowH2,
keepAliveTimeout: 500,
keepAliveMaxTimeout: 2000,
connect: { rejectUnauthorized: false },
});
const res = await fetch(`https://127.0.0.1:${port}/`, { dispatcher: agent
});
await res.text(); // body fully consumed
console.log(`allowH2=${allowH2}: response consumed, server sees ${open} open
connection(s)`);
for (const t of [1000, 2000, 3000, 5000]) {
await sleep(t === 1000 ? 1000 : 1000 + (t === 5000 ? 1000 : 0));
console.log(` t+${t}ms: open connections = ${open}${t === 5000 ? (open ?
" <-- NEVER REAPED (leak)" : " <-- reaped OK") : ""}`);
}
await agent.close();
server.close();
}
console.log("node", process.version, "| npm undici", (await
import("undici/package.json", { with: { type: "json" } })).default.version);
await probe(false); // h1: reaped at ~keepAliveTimeout
await probe(true); // h2 (the v8 default): never reaped
Output (Node v22.22.3, undici 8.4.0, Linux x64):
node v22.22.3 | npm undici 8.4.0
allowH2=false: response consumed, server sees 1 open connection(s)
t+1000ms: open connections = 0
t+2000ms: open connections = 0
t+3000ms: open connections = 0
t+5000ms: open connections = 0 <-- reaped OK
allowH2=true: response consumed, server sees 1 open connection(s)
t+1000ms: open connections = 1
t+2000ms: open connections = 1
t+3000ms: open connections = 1
t+5000ms: open connections = 1 <-- NEVER REAPED (leak)
Note when testing against public origins: the origin must actually negotiate
h2 (e.g. example.com does; www.iana.org ends up on http/1.1 and reaps fine) —
easy to false-negative a repro.
Expected Behavior
Idle h2 sessions are closed after keepAliveTimeout (or a dedicated
session-idle timeout), matching h1 pool semantics — or, at minimum, the
divergence is documented and bounded (today the only mitigation is allowH2: false or manual agent.close()).
Environment
- undici 8.0.3–8.4.0 (bisected; 7.27.2 unaffected)
- Node.js v22.22.3, Linux x64 (Debian, also reproduced in docker node:22
container)
Happy to provide more data or test a patch.
Discovered today on https://www.ai-signed.com.
When a connection negotiates HTTP/2 (
allowH2: true, the default sincev8.0.0), idle sessions are never reaped —
keepAliveTimeout/keepAliveMaxTimeouthave no effect. The socket stays ESTABLISHEDindefinitely after the response body has been fully consumed, with the client
idle in the pool (
agent.statsreportsconnected: 1, free: 1, running: 0, size: 0).The h1 path destroys an idle connection after
keepAliveTimeoutvia theparser's
TIMEOUT_KEEP_ALIVEtimer (lib/dispatcher/client-h1.js). The h2path has no equivalent:
lib/dispatcher/client-h2.jsonly arms per-streamheadersTimeout/bodyTimeouttimers (the only timer references in the file)—
keepAliveTimeoutis never consulted, and there is no session-idle timer atall.
In long-running processes that contact many distinct origins (crawlers,
scanners, webhook dispatchers), this accumulates one pinned socket + TLS/h2
session state per h2-capable origin, unbounded. Real-world data point: a
scanner of ours accumulated
3,600 pinned ESTABLISHED sockets (+1.7 GiB RSS)in 17 hours after migrating from Node's built-in fetch (bundled undici 6, h2
off) to npm undici 8 — the migration, not any version bump within 8.x, exposed
it.
Version bisect
Same test (tuned
Agent, GET, body fully consumed, h2-capable origin), socketstate checked well past
keepAliveTimeout:i.e. exactly where
allowH2flipped to default-on (v8.0.0). On 8.x withexplicit
allowH2: false, reaping works correctly — including for unconsumedbodies and HEAD. Affects both
fetch()andrequest(),Agentand bareClient.Reproducible By
Self-contained (local h2 server, self-signed cert, server-side connection
tracking — no /proc or external origins):
Output (Node v22.22.3, undici 8.4.0, Linux x64):
Note when testing against public origins: the origin must actually negotiate
h2 (e.g. example.com does; www.iana.org ends up on http/1.1 and reaps fine) —
easy to false-negative a repro.
Expected Behavior
Idle h2 sessions are closed after
keepAliveTimeout(or a dedicatedsession-idle timeout), matching h1 pool semantics — or, at minimum, the
divergence is documented and bounded (today the only mitigation is
allowH2: falseor manualagent.close()).Environment
container)
Happy to provide more data or test a patch.
Discovered today on https://www.ai-signed.com.