Which project does this relate to?
Start
Describe the bug
When a server route handler returns a Response whose headers are immutable — most commonly Response.redirect(url), but also a passed-through fetch() response — and a cookie is pending on the h3 event (via setCookie(), or indirectly via better-auth's tanstackStartCookies() plugin which calls setCookie after every auth API call), the request fails with an unhandled 500 instead of returning the redirect:
{"status":500,"unhandled":true,"message":"HTTPError"}
Root cause is mergeEventResponseHeaders in @tanstack/start-server-core (src/request-response.ts):
function mergeEventResponseHeaders(response, event) {
if (response.ok) return;
const eventSetCookies = getSetCookieValues(event.res.headers);
if (eventSetCookies.length === 0) return;
const responseSetCookies = getSetCookieValues(response.headers);
response.headers.delete("set-cookie"); // ← throws TypeError: immutable
...
}
A 3xx redirect is not response.ok, so the merge path runs, and Response.redirect() returns a response with an immutable headers guard per the Fetch spec. The TypeError propagates as an unhandled HTTPError 500.
This is easy to hit in production without realizing: any authenticated server route that ends in Response.redirect(...) breaks as soon as the auth library refreshes a session cookie during the request (better-auth with cookieCache does this on every getSession). The handler completes successfully — side effects and DB writes land — and then the framework 500s while post-processing the response, which makes it look like a routing/dispatch failure. Meanwhile unauthenticated requests to the same route (no pending cookie → early return) work fine, which is very confusing to debug.
Your Example Website or App
Minimal repro route below (any Start app):
// src/routes/api/redirect-repro.ts
import { createFileRoute } from "@tanstack/react-router";
import { setCookie } from "@tanstack/react-start/server";
export const Route = createFileRoute("/api/redirect-repro")({
server: {
handlers: {
GET: () => {
setCookie("example", "value"); // anything that puts a pending cookie on the event
return Response.redirect("https://example.com/", 302);
},
},
},
});
Steps to Reproduce the Bug or Issue
- Add the route above to a Start app.
curl -i http://localhost:3000/api/redirect-repro
- Observe
500 with body {"status":500,"unhandled":true,"message":"HTTPError"} instead of a 302.
Also reproducible directly against the dist internals:
import { requestHandler, setCookie } from "@tanstack/start-server-core/dist/esm/request-response.js";
const handler = requestHandler(async () => {
setCookie("example", "value");
return Response.redirect("https://example.com/", 302);
});
const res = await handler(new Request("http://localhost/api/x"));
console.log(res.status); // 500, cause: TypeError: immutable at mergeEventResponseHeaders
Expected behavior
The redirect should be returned (with the pending set-cookie headers merged). When the returned response has immutable headers, mergeEventResponseHeaders could clone it instead of mutating in place, e.g.:
// new Response(response.body, response) yields mutable headers
or guard the mutation with a try/catch fallback to a cloned response.
Platform
- OS: macOS / Cloudflare Workers (workerd) — reproduces on both Node (undici) and workerd, since both enforce the immutable headers guard
@tanstack/react-start: 1.168.27
@tanstack/start-server-core: 1.169.16
Additional context
Workaround for anyone hitting this: return new Response(null, { status: 302, headers: { location: url } }) instead of Response.redirect(url) from server route handlers.
Possibly related to the change that introduced event-cookie merging for non-2xx responses (#7189 / #5464) — the merge itself is desirable, it just can't assume the handler's response headers are mutable.
Which project does this relate to?
Start
Describe the bug
When a server route handler returns a
Responsewhose headers are immutable — most commonlyResponse.redirect(url), but also a passed-throughfetch()response — and a cookie is pending on the h3 event (viasetCookie(), or indirectly via better-auth'stanstackStartCookies()plugin which callssetCookieafter every auth API call), the request fails with an unhandled 500 instead of returning the redirect:{"status":500,"unhandled":true,"message":"HTTPError"}Root cause is
mergeEventResponseHeadersin@tanstack/start-server-core(src/request-response.ts):A 3xx redirect is not
response.ok, so the merge path runs, andResponse.redirect()returns a response with an immutable headers guard per the Fetch spec. TheTypeErrorpropagates as an unhandledHTTPError500.This is easy to hit in production without realizing: any authenticated server route that ends in
Response.redirect(...)breaks as soon as the auth library refreshes a session cookie during the request (better-auth withcookieCachedoes this on everygetSession). The handler completes successfully — side effects and DB writes land — and then the framework 500s while post-processing the response, which makes it look like a routing/dispatch failure. Meanwhile unauthenticated requests to the same route (no pending cookie → early return) work fine, which is very confusing to debug.Your Example Website or App
Minimal repro route below (any Start app):
Steps to Reproduce the Bug or Issue
curl -i http://localhost:3000/api/redirect-repro500with body{"status":500,"unhandled":true,"message":"HTTPError"}instead of a302.Also reproducible directly against the dist internals:
Expected behavior
The redirect should be returned (with the pending set-cookie headers merged). When the returned response has immutable headers,
mergeEventResponseHeaderscould clone it instead of mutating in place, e.g.:// new Response(response.body, response) yields mutable headersor guard the mutation with a try/catch fallback to a cloned response.
Platform
@tanstack/react-start: 1.168.27@tanstack/start-server-core: 1.169.16Additional context
Workaround for anyone hitting this: return
new Response(null, { status: 302, headers: { location: url } })instead ofResponse.redirect(url)from server route handlers.Possibly related to the change that introduced event-cookie merging for non-2xx responses (#7189 / #5464) — the merge itself is desirable, it just can't assume the handler's response headers are mutable.