Start: a client disconnect is reported as an unhandled 500, and an app cannot opt out
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 ignoresrequest.signal./api/careful— the same handler written to do everything right: it observesrequest.signal, tears down its work, and resolves a clean204. 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
- Clone the reproduction repo
bun installbun run buildbun run serve(terminal 1)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.reasonas a terminal non-error, or - Start threads a config through to
toResponse(h3 already supportssilentandonError) 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.21h3-v2: 2.0.1-rc.20srvx: 0.11.22vite: 8.2.1,nitro: 3.0.260610-beta (presetbun)- 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):
- Returning a Response from the handler doesn't help.
executeMiddlewarere-checkssignal.abortedafter awaiting the handler and throwssignal.reasonover the returned Response (dist/esm/createStartHandler.js:199-207). That's what/api/carefuldemonstrates. - A custom
server.tswrapper is too late.createStartHandlerreturnsrequestHandler(startRequestResolver)internally (dist/esm/createStartHandler.js:419), so by the time app code sees the value, h3 has already logged and converted it. - h3's escape hatches are unreachable.
toResponse(val, event, config = {})logs whenerror.unhandled && !config.silent, and marks any non-HTTPErrorasunhandled = true. Start callstoResponse(value, h3Event)with no config (dist/esm/request-response.js), so neithersilentnoronErrorcan 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
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- 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