Skip to content

v2: body read after client disconnect rejects with TypeError: Body is unusable even when the full body was received (regression from v1) #370

Description

@ruth-freewill

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:

  1. The read succeeds — the full body arrived and is sitting in the IncomingMessage buffer (this is v1's behavior via Readable.toWeb), or
  2. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    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