What version of Hono are you using?
@hono/node-server 2.0.8 (regression — 1.19.14 is not affected)
hono 4.12.28
- Node.js 25.0.0 (also observed in CI on Node 24)
What happened?
If a client sends a complete request body and then disconnects (aborts the socket) while the server is still doing async work, any subsequent body read (c.req.text() / .json() / .arrayBuffer()) rejects with TypeError: Body is unusable.
On v1.19.14, the identical scenario succeeds — the body was fully received and buffered by Node, and the read returns it.
This is easy to hit in real apps: any handler that does async work between routing and the body read (auth, context creation, an ORM call) has a window in which a client abort poisons the body. @trpc/server's fetch adapter is a prominent example — it builds the request context before reading inputs, so Playwright e2e suites that navigate away mid-mutation produce intermittent phantom BAD_REQUEST: Body is unusable errors on the server.
Possibly related but distinct from honojs/hono#4031 (throws from undici's consumeBody via the ETag middleware's double-read, not from node-server's readBodyDirect) and honojs/hono#4259 (a genuine double-read across two zValidators, fixed in hono core). This one needs no middleware and no second read — a single read after a client disconnect is enough.
Minimal reproduction
server.mjs:
import { serve } from "@hono/node-server"
import { Hono } from "hono"
const app = new Hono()
app.post("/echo", async (c) => {
// Simulate async work between routing and the body read
// (auth, context creation — what e.g. @trpc/server does)
await new Promise((r) => setTimeout(r, 15))
try {
const body = await c.req.text()
return c.json({ len: body.length })
} catch (e) {
console.error("READ FAILED:", e.constructor.name, e.message)
return c.json({ error: e.message }, 400)
}
})
serve({ fetch: app.fetch, port: 3996 }, () => console.log("up"))
client.mjs:
import http from "node:http"
// Send the FULL body, then destroy the socket while the server
// is still in its async work, before it reads the body.
const body = JSON.stringify({ hello: "world" })
const req = http.request(
{
host: "localhost",
port: 3996,
path: "/echo",
method: "POST",
headers: {
"content-type": "application/json",
"content-length": Buffer.byteLength(body),
},
},
() => {}
)
req.on("error", () => {})
req.end(body)
setTimeout(() => req.destroy(), 5)
setTimeout(() => process.exit(0), 100)
Run node server.mjs, then node client.mjs a few times.
v2.0.8 — fails every time:
READ FAILED: TypeError Body is unusable
v1.19.14 — same server/client, zero failures; the read returns the buffered body.
Expected behavior
Either of:
- The read succeeds — the full body arrived and is sitting in the
IncomingMessage buffer (this is v1's behavior via Readable.toWeb), or
- If the body genuinely cannot be delivered, an error that says what actually happened (client disconnect), not
Body is unusable, which points developers at a nonexistent double-read in their own code.
Root cause (as far as I can tell)
readBodyDirect in v2's lazy body-reading path rejects up front when Readable.isDisturbed(incoming) is true:
const readBodyDirect = (request) => {
...
if (Readable.isDisturbed(incoming)) return rejectBodyUnusable();
But Readable.isDisturbed() returns true when the stream has errored, not only when it has been read from. A client abort errors the socket/stream, so the check conflates "body already consumed" (the case Body is unusable is meant for) with "socket errored after the body fully arrived" (recoverable — the data is buffered).
A check on incoming.readableDidRead alone would distinguish the two; the errored-but-unread case could fall through to the existing data/end/close listener logic, which already produces the accurate "Client connection prematurely closed." error when the body really is incomplete.
Happy to provide more detail — we also reproduced this at load against a production-shaped app (Hono + @hono/trpc-server, 4-parallel-worker Playwright CI): ~40 phantom errors per few thousand mutations with ~8% client aborts, zero errors with no aborts.
What version of Hono are you using?
@hono/node-server2.0.8 (regression — 1.19.14 is not affected)hono4.12.28What happened?
If a client sends a complete request body and then disconnects (aborts the socket) while the server is still doing async work, any subsequent body read (
c.req.text()/.json()/.arrayBuffer()) rejects withTypeError: Body is unusable.On v1.19.14, the identical scenario succeeds — the body was fully received and buffered by Node, and the read returns it.
This is easy to hit in real apps: any handler that does async work between routing and the body read (auth, context creation, an ORM call) has a window in which a client abort poisons the body.
@trpc/server's fetch adapter is a prominent example — it builds the request context before reading inputs, so Playwright e2e suites that navigate away mid-mutation produce intermittent phantomBAD_REQUEST: Body is unusableerrors on the server.Possibly related but distinct from honojs/hono#4031 (throws from undici's
consumeBodyvia the ETag middleware's double-read, not from node-server'sreadBodyDirect) and honojs/hono#4259 (a genuine double-read across twozValidators, fixed in hono core). This one needs no middleware and no second read — a single read after a client disconnect is enough.Minimal reproduction
server.mjs:client.mjs:Run
node server.mjs, thennode client.mjsa few times.v2.0.8 — fails every time:
v1.19.14 — same server/client, zero failures; the read returns the buffered body.
Expected behavior
Either of:
IncomingMessagebuffer (this is v1's behavior viaReadable.toWeb), orBody is unusable, which points developers at a nonexistent double-read in their own code.Root cause (as far as I can tell)
readBodyDirectin v2's lazy body-reading path rejects up front whenReadable.isDisturbed(incoming)is true:But
Readable.isDisturbed()returns true when the stream has errored, not only when it has been read from. A client abort errors the socket/stream, so the check conflates "body already consumed" (the caseBody is unusableis meant for) with "socket errored after the body fully arrived" (recoverable — the data is buffered).A check on
incoming.readableDidReadalone would distinguish the two; the errored-but-unread case could fall through to the existingdata/end/closelistener logic, which already produces the accurate"Client connection prematurely closed."error when the body really is incomplete.Happy to provide more detail — we also reproduced this at load against a production-shaped app (Hono +
@hono/trpc-server, 4-parallel-worker Playwright CI): ~40 phantom errors per few thousand mutations with ~8% client aborts, zero errors with no aborts.