Bug Description
On an HTTP/2 session, a fetch() request that carries a body (e.g. a plain
string POST) is never written to the wire while another stream on the same
session is still receiving its response body. With a long-lived response such as
text/event-stream, the request is deferred forever:
- it is not multiplexed onto the session, even though HTTP/2 streams are
independent (curl against the same server multiplexes fine), and
- no second connection is opened, because with an h2 session attached the client
intentionally stops reporting pending requests as "busy" so the Pool keeps
multiplexing instead of connecting.
The result is a silent deadlock until the stream ends -- for SSE, never.
Bodyless requests (GET/HEAD) are not affected. HTTP/1.1 is not affected (the
pool correctly opens a second connection).
Reproduction
Standalone reproduction script (uses cleartext h2 via useH2c to stay
certificate-free; the same happens over TLS when ALPN negotiates h2, which
allowH2: true -- the default -- does against any h2-capable server):
'use strict'
const { test } = require('node:test')
const assert = require('node:assert')
const { createServer } = require('node:http2')
const { once } = require('node:events')
const { fetch, Agent } = require('undici')
test('fetch POST dispatches while an SSE stream is open on the same h2 session', { timeout: 30000 }, async (t) => {
const server = createServer()
server.on('stream', (stream, headers) => {
if (headers[':method'] === 'GET') {
stream.respond({ ':status': 200, 'content-type': 'text/event-stream' })
stream.write(': ping\n\n') // stream is held open, like a real SSE endpoint
} else {
stream.respond({ ':status': 200, 'content-type': 'application/json' })
stream.end('{"ok":true}')
}
})
server.listen(0, '127.0.0.1')
await once(server, 'listening')
const dispatcher = new Agent({ useH2c: true })
const sse = new AbortController()
t.after(async () => {
sse.abort()
await dispatcher.destroy()
server.close()
})
const origin = `http://127.0.0.1:${server.address().port}`
// Establish the h2 session.
const warmup = await fetch(origin, { method: 'POST', body: '{"warmup":1}', dispatcher })
await warmup.text()
// Open a never-ending SSE stream on that session.
fetch(origin, { dispatcher, signal: sse.signal }).catch(() => {})
// A POST to the same origin should multiplex onto the session (or get a
// new connection) and resolve immediately. With the bug it is never
// written to the wire and times out.
const res = await fetch(origin, {
method: 'POST',
body: '{"real":1}',
dispatcher,
signal: AbortSignal.timeout(5000),
})
assert.strictEqual(res.status, 200)
})
Steps to reproduce (if not using the script above):
Also reproducible with the default global fetch and no options at all: serve
the same handler from node:http2.createSecureServer with a self-signed
certificate, warm the session with one POST, fetch(origin) to open the SSE
stream, then fetch(origin, { method: 'POST', body: '...' }) -- ALPN negotiates
h2 and the POST hangs. Equally reproducible against public h2 servers that hold
an SSE stream open (see Additional context).
Expected Behavior
The second POST resolves immediately: HTTP/2 streams are independent, so the
request should be multiplexed onto the open session (this is what curl does
against the same server), or failing that, dispatched on a new connection.
Actual Behavior
The POST times out (TimeoutError: The operation was aborted due to timeout);
without a signal it hangs indefinitely. Diagnostics channels show the request is
never dispatched: undici:client:sendHeaders never fires for it and
undici:client:connected fires exactly once (no second connection). It stays
queued until the SSE stream ends.
Logs & Screenshots
node --test output of the script above:
TimeoutError: The operation was aborted due to timeout
at new DOMException (node:internal/per_context/domexception:79:18)
at Timeout._onTimeout (node:internal/abort_controller:210:9)
...
tests 1 / pass 0 / fail 1 (5019ms)
Instrumented dispatch decisions (h2 session, warm socket; logging added to
Client[kDispatch]):
[client.dispatch] GET resuming=0 busy=false needDrain=0 -> returns true <- SSE stream accepted
[client.dispatch] POST resuming=1 busy=false needDrain=0 -> returns true <- accepted, then parked forever
The same sequence over HTTP/1.1 reports busy=true -> returns false, the pool
opens a second connection, and the POST completes.
Environment
- OS: macOS (Darwin 25.5.0)
- Node.js version: v26.4.0
- undici version: 8.7.0 (npm); also reproduced with 8.5.0 as bundled in Node
v26.4.0
Additional context
Root cause, as far as we can tell:
lib/dispatcher/client-h2.js -> busy(request): when kRunning > 0, a
request whose body is a stream/async iterable is deferred until all in-flight
streams complete ("Request with stream or iterator body cannot be retried.
Ensure that no other requests are inflight."). fetch() extracts every
non-empty body to a stream, so all bodied fetch requests hit this guard --
which contradicts the comment a few lines above it stating that h2 dispatches
non-idempotent requests concurrently.
lib/dispatcher/client.js -> get [kBusy]: with an h2 context attached,
kPending > 0 is deliberately not reported as busy ("we want concurrent
dispatches to multiplex onto the shared session"), so Pool never opens a
second connection. Combined with the guard above, a deferred bodied request
has no escape route.
- Requests without bodies skip the guard, so GETs multiplex fine -- only bodied
requests starve.
Real-world impact: MCP (Model Context Protocol) StreamableHTTP clients on Node
hang against h2-hosted MCP servers (e.g. Cloudflare Workers: try
https://docs.mcp.cloudflare.com/mcp). The transport opens a standalone SSE GET
right after the handshake and then POSTs JSON-RPC calls (tools/list, tool
invocations) to the same origin; every POST parks behind the SSE stream and the
client times out. Reports in that ecosystem (e.g. geelen/mcp-remote#226) look
like this bug misattributed. The blast radius grew when allowH2 became true
by default.
Workarounds
new Agent({ allowH2: false }) (per request or via setGlobalDispatcher):
forces HTTP/1.1, where the pool opens a second connection past the busy one.
- Give long-lived streams their own dispatcher: with the SSE request on a
separate Agent, bodied requests no longer share its session and dispatch
immediately (verified against the reproduction script).
Bug Description
On an HTTP/2 session, a
fetch()request that carries a body (e.g. a plainstring POST) is never written to the wire while another stream on the same
session is still receiving its response body. With a long-lived response such as
text/event-stream, the request is deferred forever:independent (curl against the same server multiplexes fine), and
intentionally stops reporting pending requests as "busy" so the
Poolkeepsmultiplexing instead of connecting.
The result is a silent deadlock until the stream ends -- for SSE, never.
Bodyless requests (GET/HEAD) are not affected. HTTP/1.1 is not affected (the
pool correctly opens a second connection).
Reproduction
Standalone reproduction script (uses cleartext h2 via
useH2cto staycertificate-free; the same happens over TLS when ALPN negotiates h2, which
allowH2: true-- the default -- does against any h2-capable server):Steps to reproduce (if not using the script above):
Also reproducible with the default global
fetchand no options at all: servethe same handler from
node:http2.createSecureServerwith a self-signedcertificate, warm the session with one POST,
fetch(origin)to open the SSEstream, then
fetch(origin, { method: 'POST', body: '...' })-- ALPN negotiatesh2 and the POST hangs. Equally reproducible against public h2 servers that hold
an SSE stream open (see Additional context).
Expected Behavior
The second POST resolves immediately: HTTP/2 streams are independent, so the
request should be multiplexed onto the open session (this is what curl does
against the same server), or failing that, dispatched on a new connection.
Actual Behavior
The POST times out (
TimeoutError: The operation was aborted due to timeout);without a signal it hangs indefinitely. Diagnostics channels show the request is
never dispatched:
undici:client:sendHeadersnever fires for it andundici:client:connectedfires exactly once (no second connection). It staysqueued until the SSE stream ends.
Logs & Screenshots
node --testoutput of the script above:Instrumented dispatch decisions (h2 session, warm socket; logging added to
Client[kDispatch]):The same sequence over HTTP/1.1 reports
busy=true -> returns false, the poolopens a second connection, and the POST completes.
Environment
v26.4.0
Additional context
Root cause, as far as we can tell:
lib/dispatcher/client-h2.js->busy(request): whenkRunning > 0, arequest whose body is a stream/async iterable is deferred until all in-flight
streams complete ("Request with stream or iterator body cannot be retried.
Ensure that no other requests are inflight.").
fetch()extracts everynon-empty body to a stream, so all bodied fetch requests hit this guard --
which contradicts the comment a few lines above it stating that h2 dispatches
non-idempotent requests concurrently.
lib/dispatcher/client.js->get [kBusy]: with an h2 context attached,kPending > 0is deliberately not reported as busy ("we want concurrentdispatches to multiplex onto the shared session"), so
Poolnever opens asecond connection. Combined with the guard above, a deferred bodied request
has no escape route.
requests starve.
Real-world impact: MCP (Model Context Protocol) StreamableHTTP clients on Node
hang against h2-hosted MCP servers (e.g. Cloudflare Workers: try
https://docs.mcp.cloudflare.com/mcp). The transport opens a standalone SSE GETright after the handshake and then POSTs JSON-RPC calls (
tools/list, toolinvocations) to the same origin; every POST parks behind the SSE stream and the
client times out. Reports in that ecosystem (e.g. geelen/mcp-remote#226) look
like this bug misattributed. The blast radius grew when
allowH2becametrueby default.
Workarounds
new Agent({ allowH2: false })(per request or viasetGlobalDispatcher):forces HTTP/1.1, where the pool opens a second connection past the busy one.
separate
Agent, bodied requests no longer share its session and dispatchimmediately (verified against the reproduction script).