Repository navigation
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
Description
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
Measure coverage and scan it with SonarQube Cloud in CI (#426)
Kesavamoorthig06 commentedon Oct 9, 2026
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.