Skip to content

[start] Unhandled 500 (TypeError: immutable) when a server route returns Response.redirect() while a set-cookie is pending on the event #7755

Description

@SomtoUgeh

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

  1. Add the route above to a Start app.
  2. curl -i http://localhost:3000/api/redirect-repro
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions