[GH-ISSUE #660] bug: headers()/cookies() throw "can only be called from a Server Component" on warm requests — clearRequestContext() races the RSC/SSR stream #140

Closed
opened 2026-05-06 12:37:35 +02:00 by BreizhHardware · 0 comments

Originally created by @james-elicx on GitHub (Mar 22, 2026).
Original GitHub issue: https://github.com/cloudflare/vinext/issues/660

Originally assigned to: @james-elicx on GitHub.

Bug

headers() and cookies() throw "can only be called from a Server Component, Route Handler, or Server Action" inside App Router Server Components on second and subsequent page loads. The first load always works.

Root cause

The RSC and SSR pipelines are lazy ReadableStream pipelines. renderToReadableStream() returns a stream handle — the Server Component tree has not executed yet. Components run lazily as the stream is pulled by the HTTP response consumer.

However, renderAppPageLifecycle() in src/server/app-page-render.ts calls clearRequestContext() immediately after receiving the stream handle, before the stream has been consumed:

// src/server/app-page-render.ts
const htmlStream = htmlRender.htmlStream;  // stream handle only — components haven't run yet
// ...
options.clearRequestContext();             // ❌ sets headersContext = null too early
// ...
return buildAppPageHtmlResponse(htmlStream, { ... });  // stream still live

The execution sequence:

  1. renderToReadableStream(rscElement) → returns RSC stream handle (components not yet run)
  2. RSC stream piped into handleSsr() → returns HTML stream handle (still not run)
  3. clearRequestContext() called — headersContext set to null on the live ALS store object
  4. Response sent to browser, which begins pulling the stream
  5. Server Components now execute and call headers() — but headersContext is already null

Why the first load works: On a cold module graph, module loading is slow enough that the stream happens to drain (steps 4–5) before the event loop gets to step 3. On subsequent warm loads modules are cached, so step 3 consistently wins the race.

Why isInsideUnifiedScope() is true: The ALS store itself is still set (the request scope is live). clearRequestContext() doesn't exit the ALS scope — it mutates the shared store object in-place, setting headersContext = null. Since the stream holds a reference to the same object, it sees the null immediately.

Affected files

  • src/server/app-page-render.ts — primary site (renderAppPageLifecycle)
  • src/server/app-page-stream.tsrenderAppPageHtmlResponse() has the same pattern

Proposed fix

Defer clearRequestContext() until the HTML stream is fully consumed by piping through a TransformStream whose flush() does the cleanup. Values that need to be read now (before the stream drains) — getDraftModeCookieHeader(), consumeDynamicUsage() — should still be called eagerly; only the nulling-out of the context needs to move.

// src/server/app-page-render.ts

// BEFORE
options.clearRequestContext();
return buildAppPageHtmlResponse(htmlStream, { ... });

// AFTER
const cleanup = new TransformStream({
  flush() { options.clearRequestContext(); },
});
return buildAppPageHtmlResponse(htmlStream.pipeThrough(cleanup), { ... });

The same change is needed in renderAppPageHtmlResponse() in app-page-stream.ts and in the ISR cache write path in finalizeAppPageHtmlCacheResponse().

Note that simply moving the clearRequestContext() call later in the same synchronous function body is not enough — the stream is consumed asynchronously after the Response is returned, so any synchronous call site still races it.

Repro

Any App Router Server Component that calls headers() or cookies():

// app/page.tsx
import { headers } from 'next/headers';

export default async function Page() {
  const h = await headers();
  return <div>{h.get('host')}</div>;
}

Works on first load, throws on every subsequent reload.

Workaround

Wrap the headers() call in React.cache() so it is resolved during the first synchronous component invocation (before any await yields control back to the stream machinery) and memoised for all other components in the same request. This sidesteps the race but does not fix the root cause.

Originally created by @james-elicx on GitHub (Mar 22, 2026). Original GitHub issue: https://github.com/cloudflare/vinext/issues/660 Originally assigned to: @james-elicx on GitHub. ## Bug `headers()` and `cookies()` throw "can only be called from a Server Component, Route Handler, or Server Action" inside App Router Server Components on **second and subsequent page loads**. The first load always works. ## Root cause The RSC and SSR pipelines are lazy `ReadableStream` pipelines. `renderToReadableStream()` returns a **stream handle** — the Server Component tree has not executed yet. Components run lazily as the stream is *pulled* by the HTTP response consumer. However, `renderAppPageLifecycle()` in `src/server/app-page-render.ts` calls `clearRequestContext()` **immediately after receiving the stream handle**, before the stream has been consumed: ```ts // src/server/app-page-render.ts const htmlStream = htmlRender.htmlStream; // stream handle only — components haven't run yet // ... options.clearRequestContext(); // ❌ sets headersContext = null too early // ... return buildAppPageHtmlResponse(htmlStream, { ... }); // stream still live ``` The execution sequence: 1. `renderToReadableStream(rscElement)` → returns RSC stream handle (components not yet run) 2. RSC stream piped into `handleSsr()` → returns HTML stream handle (still not run) 3. ❌ `clearRequestContext()` called — `headersContext` set to `null` on the live ALS store object 4. `Response` sent to browser, which begins pulling the stream 5. Server Components now execute and call `headers()` — but `headersContext` is already `null` **Why the first load works:** On a cold module graph, module loading is slow enough that the stream happens to drain (steps 4–5) before the event loop gets to step 3. On subsequent warm loads modules are cached, so step 3 consistently wins the race. **Why `isInsideUnifiedScope()` is `true`:** The ALS store itself is still set (the request scope is live). `clearRequestContext()` doesn't exit the ALS scope — it **mutates the shared store object in-place**, setting `headersContext = null`. Since the stream holds a reference to the same object, it sees the null immediately. ## Affected files - `src/server/app-page-render.ts` — primary site (`renderAppPageLifecycle`) - `src/server/app-page-stream.ts` — `renderAppPageHtmlResponse()` has the same pattern ## Proposed fix Defer `clearRequestContext()` until the HTML stream is **fully consumed** by piping through a `TransformStream` whose `flush()` does the cleanup. Values that need to be read *now* (before the stream drains) — `getDraftModeCookieHeader()`, `consumeDynamicUsage()` — should still be called eagerly; only the nulling-out of the context needs to move. ```ts // src/server/app-page-render.ts // BEFORE options.clearRequestContext(); return buildAppPageHtmlResponse(htmlStream, { ... }); // AFTER const cleanup = new TransformStream({ flush() { options.clearRequestContext(); }, }); return buildAppPageHtmlResponse(htmlStream.pipeThrough(cleanup), { ... }); ``` The same change is needed in `renderAppPageHtmlResponse()` in `app-page-stream.ts` and in the ISR cache write path in `finalizeAppPageHtmlCacheResponse()`. Note that simply moving the `clearRequestContext()` call later in the same *synchronous* function body is **not enough** — the stream is consumed asynchronously after the `Response` is returned, so any synchronous call site still races it. ## Repro Any App Router Server Component that calls `headers()` or `cookies()`: ```ts // app/page.tsx import { headers } from 'next/headers'; export default async function Page() { const h = await headers(); return <div>{h.get('host')}</div>; } ``` Works on first load, throws on every subsequent reload. ## Workaround Wrap the `headers()` call in `React.cache()` so it is resolved during the first synchronous component invocation (before any `await` yields control back to the stream machinery) and memoised for all other components in the same request. This sidesteps the race but does not fix the root cause.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
starred/vinext#140
No description provided.