Skip to content

Start: a client disconnect is reported as an unhandled 500, and an app cannot opt out #7991

Description

@jwaltz

Which project does this relate to?

Start

Describe the bug

When a client goes away mid-request — a reload, a closed tab, an aborted fetch — Start rejects the request with signal.reason, and h3 classifies that rejection as an unhandled server error: a console.error plus a 500, once per abandoned request.

The cancellation propagation itself is correct and welcome (it landed for us in @tanstack/start-server-core@1.169.19; before that, disconnects were silently ignored). What's missing is the classification: a client hanging up is a normal outcome, but Start has no vocabulary to distinguish it from a genuine failure, and no seam for the app to supply one.

This matters most for routes that hold long-lived upstream connections. We proxy ElectricSQL shape requests; a single authenticated page holds ~10 long-polls, so every reload emits 10 unhandled 500s. At that rate the log noise buries real errors.

The reproduction contains two routes:

  • /api/naive — a long-lived handler that ignores request.signal.
  • /api/careful — the same handler written to do everything right: it observes request.signal, tears down its work, and resolves a clean 204. It never rejects.

Both produce an identical unhandled 500, which is the core of the report — the app-side fix does not exist.

Complete minimal reproducer

https://github.com/jwaltz/start-client-disconnect-repro

Steps to Reproduce the Bug or Issue

  1. Clone the reproduction repo
  2. bun install
  3. bun run build
  4. bun run serve (terminal 1)
  5. bun run reproduce (terminal 2) — starts each request and aborts it after 250 ms

Expected behavior

A request abandoned by its own client should not be logged as an unhandled server error. Ideally either:

  • Start treats a rejection that is exactly the aborted request's signal.reason as a terminal non-error, or
  • Start threads a config through to toResponse (h3 already supports silent and onError) so an app can classify it.

Genuine upstream failures must stay visible either way — including the race where a real error is thrown and the client disconnects immediately afterward.

Screenshots or Videos

Server output — one block per disconnect, including the handler that returned a clean 204:

error: The connection was closed.
      cause: DOMException { code: 20, name: "AbortError", message: "The connection was closed." },
     status: 500,
  unhandled: true,

      at prepareResponse (.../h3-v2.mjs:181:37)
      at toResponse (.../h3-v2.mjs:150:19)

Platform

  • @tanstack/react-start: 1.168.38
  • @tanstack/start-server-core: 1.169.21
  • @tanstack/react-router: 1.170.21
  • h3-v2: 2.0.1-rc.20
  • srvx: 0.11.22
  • vite: 8.2.1, nitro: 3.0.260610-beta (preset bun)
  • Bun: 1.4.0-canary.1

Also observed under the Node dev adapter, where the abort originates in srvx/dist/adapters/node.mjs on early response close — so this is not Bun-specific.

Additional context

Three seams we checked, all closed (paths in @tanstack/start-server-core@1.169.21):

  1. Returning a Response from the handler doesn't help. executeMiddleware re-checks signal.aborted after awaiting the handler and throws signal.reason over the returned Response (dist/esm/createStartHandler.js:199-207). That's what /api/careful demonstrates.
  2. A custom server.ts wrapper is too late. createStartHandler returns requestHandler(startRequestResolver) internally (dist/esm/createStartHandler.js:419), so by the time app code sees the value, h3 has already logged and converted it.
  3. h3's escape hatches are unreachable. toResponse(val, event, config = {}) logs when error.unhandled && !config.silent, and marks any non-HTTPError as unhandled = true. Start calls toResponse(value, h3Event) with no config (dist/esm/request-response.js), so neither silent nor onError can be supplied.

The only mechanism we found was patching request-response.js to inspect the rejection before it reaches h3. That works, but pins the app to one exact transitive version of start-server-core — a routine dependency bump silently un-applies it — so we've reverted it and are living with the noise.

Possibly related: #6069 (uncatchable "connection was closed" DOMException flooding logs), #7873 (serverFnFetcher logging benign cancellation AbortErrors), #7748 (client abort mid-SSR crashing vite dev via the stream watchdog), #7929 (aborted SSR render returning 200). They look like the same missing concept viewed from four places: no shared notion of "this stopped because the client left."

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions