Skip to content

HTTP/2: fetch POST hangs while an SSE stream is open #5524

Description

@percederberg-mermaid

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).

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions