Skip to content

fix(start-server-core): return 499 when the client disconnects mid-request - #8134

Open
naoya7076 wants to merge 2 commits into
TanStack:mainfrom
naoya7076:fix/client-disconnect-499
Open

fix(start-server-core): return 499 when the client disconnects mid-request#8134
naoya7076 wants to merge 2 commits into
TanStack:mainfrom
naoya7076:fix/client-disconnect-499

Conversation

@naoya7076

@naoya7076 naoya7076 commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Fixes #7991.

Problem

startRequestResolver in createStartHandler has no catch around its try/finally. When a client disconnects, the abort is re-thrown internally as signal.reason so that in-flight work unwinds and gets disposed. That rejection then escapes the request boundary. h3 marks it as unhandled, logs it with console.error, and responds 500.

The app has no way to opt out:

  • Start calls toResponse(value, h3Event) without a config, so h3's silent / onError options never apply.
  • A handler that observes the signal and returns a response gets discarded, because executeMiddleware re-throws signal.reason at the end when the signal is aborted.

More background in my comment on the issue: #7991 (comment)

Change

Catch the abort at the outermost request boundary and return 499 Client Closed Request:

} catch (err) {
  if (request.signal.aborted && err === request.signal.reason) {
    return new Response(null, { status: 499, statusText: 'Client Closed Request' })
  }
  throw err
}
  • The internal throw signal.reason calls are untouched. They are still needed to drive cleanup. Only the final classification changes.
  • The check compares identity with request.signal.reason instead of matching err.name === 'AbortError', so unrelated AbortErrors thrown by app code still propagate as real errors.
  • This is option 1 from Start: a client disconnect is reported as an unhandled 500, and an app cannot opt out #7991 (comment). The fix sits at the shared boundary, so it covers SSR, server routes, and server functions at once.
  • A patch changeset for @tanstack/start-server-core is included.

Why 499

Per RFC 9110 §15.6, 5xx means the server itself failed. A disconnect is the client withdrawing the request, so 500 misclassifies it. There is no standard code for this case because the response never reaches the client; it only matters for logs and middleware. nginx's non-standard 499 (NGX_HTTP_CLIENT_CLOSED_REQUEST) became the de facto convention for it.

The adjacent layers already handle it this way: h3's proxy() returns 499 when event.req.signal.aborted (since 2.0.1-rc.23), and srvx's node adapter suppresses console.error for aborted requests. Start is currently the only layer in the stack that turns a disconnect into an unhandled 500.

Behavioral change

Disconnects that previously produced a 500 and an error log now produce a 499 and no log. If you alert on 5xx rates, expect fewer entries. The client never sees either response, so nothing changes on the wire.

Tests

13 existing assertions in the createStartHandler request cancellation suite expected status === 500 after requestController.abort(...). They now expect 499. That is the whole extent of the behavior change. The side-effect assertions in those tests (cleanup runs once, streams disposed with the abort reason, render never called) are unchanged. The one 500 assertion that is not abort-related (middleware throwing a real error) is also unchanged.

Three new tests assert that a disconnect returns 499 Client Closed Request on each request path:

  • SSR render
  • server route handler
  • server function call

To run locally:

pnpm nx run @tanstack/start-server-core:test:unit -- tests/createStartHandler.test.ts

test:unit (33 passed), test:types, and test:eslint pass on the package.

Verification

I applied this diff with pnpm patch to @tanstack/start-server-core@1.169.26 in a production Start app (server routes proxying Connect RPC to a backend), and compared the same screens with and without it:

AbortError logs unhandled 500s
without patch 6 6
with patch 0 0

Related PRs

If you prefer a different status code, or want to settle the policy discussion in #7991 first, I'm happy to adjust.

Summary by CodeRabbit

  • Bug Fixes
    • Client disconnects during server-side rendering, route handling, or server function calls now return HTTP 499 “Client Closed Request” instead of an internal server error.
    • Prevents unnecessary rendering after a client connection has been closed.
  • Release
    • Includes a patch release for the server core package.

@coderabbitai

coderabbitai Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 8e8be209-89be-485b-85bc-044036e64362

📥 Commits

Reviewing files that changed from the base of the PR and between 54ea3c7 and d5b344a.

📒 Files selected for processing (1)
  • packages/start-server-core/src/createStartHandler.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 7 remain after this review.


📝 Walkthrough

Walkthrough

The request resolver now returns HTTP 499 when the caught error matches the request signal’s abort reason. Tests cover SSR, middleware, route handlers, route work, and server functions.

Changes

Client disconnect handling

Layer / File(s) Summary
Map request aborts to HTTP 499
packages/start-server-core/src/createStartHandler.ts
Matching request-signal abort reasons return an empty response with status 499 and status text Client Closed Request. Other errors are rethrown.
Validate cancellation responses
packages/start-server-core/tests/createStartHandler.test.ts, .changeset/client-disconnect-499.md
Existing cancellation expectations now use status 499. New tests cover SSR, server route handlers, and server functions, including status text and skipped rendering assertions. The changeset records a patch release.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Merge Risk: ⚪ Minimal · up to d5b34

Client disconnects will be reported as 499 instead of logged 500 responses, without changing the response seen by clients. No actionable merge-blocking risk remains beyond normal checks and review.

Suggested reviewers: schiller-manuel, sheraff

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the primary change: returning HTTP 499 for client disconnects.
Linked Issues check ✅ Passed The implementation classifies matching client disconnects as HTTP 499, preserves unrelated errors and cleanup, and covers SSR, routes, and server functions.
Out of Scope Changes check ✅ Passed The code, tests, and changeset directly support client-disconnect handling and the linked issue objectives.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 1 functions across 2 files.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@naoya7076
naoya7076 force-pushed the fix/client-disconnect-499 branch from 2dc9b0c to 6f47d9c Compare August 21, 2026 04:30
…quest

When a client disconnects, the abort reason is re-thrown internally to
unwind in-flight work and dispose abandoned results. Previously it then
escaped the request boundary, so h3 flagged it as an unhandled error and
logged a 500 — with no way for the app to opt out (executeMiddleware's
terminal check discards any response produced after the abort).

Catch the abort at the outermost request boundary and classify it as
499 Client Closed Request instead. The check uses identity comparison
against request.signal.reason so unrelated AbortErrors thrown by app
code are not swallowed.

Fixes TanStack#7991
@naoya7076
naoya7076 force-pushed the fix/client-disconnect-499 branch from 54ea3c7 to d5b344a Compare August 21, 2026 04:56
@radist2s

Copy link
Copy Markdown

Thanks for putting this together. We've been hitting this exact issue repeatedly in Playwright E2E: routine client disconnects produce a wall of AbortError / unhandled 500 logs even though the suite succeeds. Classifying the request's own abort reason as 499 at the Start boundary looks like the right fix and would make CI logs much more useful. We'd really appreciate seeing this merged.

@Sheraff

Sheraff commented Aug 22, 2026

Copy link
Copy Markdown
Collaborator

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

3 participants