Skip to content

A Request reads one chunk of an async-iterable body before anyone reads it (since 8.11.0), which stalls Node's keep-alive sockets under Next.js #5988

Open

Description

@kaufmo

Bug Description

Since 8.11.0, new Request(url, { body: asyncIterable, duplex: 'half' }) pulls one chunk from the iterable at construction, before anything reads the body. 8.10.2 pulls nothing until the body is read.

The change is in extractBody (lib/web/fetch/body.js):

-    stream =
-      webidl.is.ReadableStream(object) ? object : ReadableStreamFrom(object)
+    stream = webidl.is.ReadableStream(object)
+      ? object
+      : ReadableStream.from(object).pipeThrough(new TransformStream({ … }))

pipeThrough starts piping at once, and the transform's writable side (high-water mark 1) asks for a chunk, so the source's iterator is started and read.

When the iterable is a server-side http.IncomingMessage, that early read leaves a 'readable' listener on it, from the Readable's async iterator. If the handler then answers without reading the body, Node's server calls req._dump() → resume(), which has no effect while a 'readable' listener is attached. The rest of the body is never drained, and the next request on that keep-alive socket is never parsed.

Next.js does exactly this. NextRequestAdapter.fromNodeNextRequest passes the IncomingMessage as the body of the NextRequest. So on Node 26.11.0 and later, which bundle undici 8.11.2, any route that answers a PUT/POST without reading its body, such as a 413 on Content-Length, stalls that connection. A client that reuses it hangs.

Reproduction

Standalone reproduction script:

'use strict'

const { test } = require('node:test')
const assert = require('node:assert')
const { createServer, request, Agent } = require('node:http')
const { once } = require('node:events')
const { Request } = require('undici')

test('a Request over an unread IncomingMessage leaves a keep-alive socket usable', { timeout: 60000 }, async (t) => {
  // An async iterable that counts the reads: nothing reads the Request's body.
  let pulls = 0
  const counted = { [Symbol.asyncIterator]: () => ({ next: async () => { pulls++; return { done: false, value: new Uint8Array(1) } } }) }
  new Request('http://localhost/', { method: 'PUT', body: counted, duplex: 'half' }) // eslint-disable-line no-new
  await new Promise((resolve) => setTimeout(resolve, 50))

  // As Next.js does in `NextRequestAdapter.fromNodeNextRequest`: the server's
  // IncomingMessage becomes the body of a Request, and the handler answers without
  // reading it.
  const server = createServer((req, res) => {
    new Request(`http://localhost${req.url}`, { method: req.method, body: req, duplex: 'half' }) // eslint-disable-line no-new
    setTimeout(() => res.writeHead(413).end(), 50)
  })
  server.listen(0, '127.0.0.1')
  await once(server, 'listening')
  const agent = new Agent({ keepAlive: true, maxSockets: 1 })
  t.after(() => { agent.destroy(); server.close() })

  const put = (bytes) => new Promise((resolve) => {
    const req = request({ port: server.address().port, host: '127.0.0.1', method: 'PUT', agent, timeout: 3000 }, (res) => {
      res.resume().on('end', () => resolve(res.statusCode))
    })
    req.on('error', () => {})
    req.on('timeout', () => { req.destroy(); resolve('timed out') })
    req.end(Buffer.alloc(bytes))
  })

  const first = await put(1024 * 1024)
  const second = await put(16) // on the same socket, after the first one's unread body

  assert.deepStrictEqual({ pulls, first, second }, { pulls: 0, first: 413, second: 413 })
})

node --test repro.js passes with undici 8.10.2 and fails with 8.11.0, 8.11.1 and 8.11.2, all on Node 26.10.0. With the undici built into Node, it passes on 26.10.0 and fails on 26.11.0 and 26.11.1.

Expected Behavior

Constructing a Request does not read from its body source, as with 8.10.2. A server that answers without reading the request body still has the body drained by Node, and the keep-alive socket serves the next request.

Actual Behavior

actual:   { pulls: 1, first: 413, second: 'timed out' }
expected: { pulls: 0, first: 413, second: 413 }

Inside the server, the first request ends up with readableFlowing === false, a 'readable' listener from Readable.prototype[Symbol.asyncIterator] (pulled through ReadableStream.from → pipeThrough), and complete === false after the response finished. _dump() → resume() never brings it to 'end'.

Environment

  • OS: macOS 26 (arm64); also seen on GitHub Actions ubuntu-latest
  • Node.js version: 26.10.0 with the undici package, 26.11.0 and 26.11.1 with the built-in one
  • undici version: 8.11.0, 8.11.1, 8.11.2 (8.10.2 is not affected)

Additional context

Found through a Next.js 16.3.8 app under next start on Node 26.11.1. After a 413 to a PUT with Content-Length whose body the route did not read, the next request that the client sent on the same socket timed out every time. The same app on Node 26.10.0 does not hang.

Activity

  1. Kesavamoorthig06 commented on Oct 9, 2026

    @Kesavamoorthig06

    Opened #5990 with a lazy body extraction fix and regression tests. Reproduced the eager pull and keep-alive stall locally; both pass with the fix, along with the fetch suite.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions