TanStack / TanStack/router

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

Open
#7,991 4 comments 7 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
TypeScript
Stars
15.1k
Forks
1.9k
Avg merge
1d 20h
Merged PRs (30d)
143

Description

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."

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with dist/esm/createStartHandler.js, especially the signal check in lines 199-207, then inspect dist/esm/request-response.js and its toResponse call. Run the linked reproducer with the listed Bun commands and compare /api/naive with /api/careful. Done means client disconnects no longer produce unhandled 500 logging while genuine upstream failures remain visible.

Written by the indexing model from the issue text.

Assessment

Tech stack
bun, typescript, vite
Domain
api, backend
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
48/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.