Link to the code that reproduces this issue
https://github.com/himself65/next-e1378-after-phase-arming
To Reproduce
The repro has two byte-identical dynamic pages, except /armed calls after(() => {}) before a slow await and /unarmed does not:
// app/armed/page.tsx
import { headers } from "next/headers";
import { after } from "next/server";
export const dynamic = "force-dynamic";
export default async function Armed() {
after(() => {}); // the only difference from /unarmed
await new Promise((r) => setTimeout(r, 5000)); // stand-in for a DB/auth round-trip
const h = await headers();
return <p>{h.get("host")}</p>;
}
pnpm install && pnpm build && pnpm start
- Abort a request to each route mid-await:
curl --max-time 1 http://localhost:3000/armed and curl --max-time 1 http://localhost:3000/unarmed
- Watch the server log for ~5 seconds
No experimental flags are enabled.
Current vs. Expected behavior
Current: /armed logs, ~5s after the aborted request:
⨯ Error: Route /armed used `headers()` inside `after()` while rendering. This is not supported. If you need this data inside an `after()` callback, use `headers()` outside of the callback. See more info here: https://nextjs.org/docs/app/api-reference/functions/after
digest: '693266121@E1378'
/unarmed — the same code minus the unrelated after() call — never errors.
Expected: either both pages error (if "request APIs must be called before the response closes" is the contract) or neither does. Whether await io(); await headers() throws should not depend on whether some other code called after() earlier in the same request.
Note the error text is also misleading for this case: there is no after() callback anywhere near the reported frame — the read is in the page body, which resumed after the response closed.
Why this happens (source reading)
AfterContext.after() registers the current work-unit store in workUnitStores (packages/next/src/server/after/after-context.ts).
- The
onClose handler flips phase = 'after' only for registered stores — the code carries this TODO:
TODO(after): it's not ideal that we'll only switch the phase of a WorkUnitStore if after() was called inside it. We should probably track this whenever a store is created
- A page function parked on a non-abortable await keeps executing after the response closes. Its next request-API call runs
isRequestApiAllowedInCurrentPhase (packages/next/src/server/request/utils.ts), which rejects phase 'after' on pages → E1378.
So the throw requires an arming step (any after() call earlier in the request) plus a race (response closed before the continuation ran). Identical code is legal or throwing depending on both.
Real-world impact
In our production app the arming call was a session sliding-window helper inside auth() — i.e. every authenticated request is armed. The result was a steady stream of E1378 noise on client aborts (and, with cacheComponents + runtime prefetches, on prefetch responses closing before uncached IO settles), spread across every route, with stack frames pointing at innocent await headers() lines far from any after(). It took decoding minified prod frames to connect the error to the after() call in the auth helper, because nothing in the message or stack mentions the arming site.
Suggested directions
The current middle state — throws only if armed, only on a close race — is the hardest version to operate: it cannot be caught deterministically in dev and surfaces as unattributable prod noise. Either direction would fix that:
- If pre-close calls are the intended contract: complete the TODO and flip the phase for every store at close, so the error is deterministic (fires on the first client abort in dev, same code always behaves the same), and document the contract on the
after() page.
- If not: let
headers()/cookies() called in a render's after phase resolve the already-captured request values — they are fixed at request start and the store still holds them — reserving the error for after() callbacks proper, where the message is accurate.
Meanwhile we work around it by pinning request-API calls before the first await (const p = headers(); await io; const h = await p) and enforcing that with a custom ESLint rule — workable, but it encodes an internal race semantics apps shouldn't have to know about.
Provide environment information
Operating System:
Platform: darwin
Arch: arm64
Version: Darwin Kernel Version 25.6.0
Binaries:
Node: 26.8.1
npm: 11.19.0
pnpm: 10.33.0
Relevant Packages:
next: 16.3.3 // Latest available version is detected (16.3.3).
react: 19.2.8
react-dom: 19.2.8
typescript: 7.0.2
Next.js Config:
output: N/A
Also reproduced on a production deployment (Node standalone, Azure Container Apps).
Which area(s) are affected? (Select all that apply)
Runtime APIs (headers/cookies), after()
Which stage(s) are affected? (Select all that apply)
next start (local), Deployed (production), next dev
Link to the code that reproduces this issue
https://github.com/himself65/next-e1378-after-phase-arming
To Reproduce
The repro has two byte-identical dynamic pages, except
/armedcallsafter(() => {})before a slow await and/unarmeddoes not:pnpm install && pnpm build && pnpm startcurl --max-time 1 http://localhost:3000/armedandcurl --max-time 1 http://localhost:3000/unarmedNo experimental flags are enabled.
Current vs. Expected behavior
Current:
/armedlogs, ~5s after the aborted request:/unarmed— the same code minus the unrelatedafter()call — never errors.Expected: either both pages error (if "request APIs must be called before the response closes" is the contract) or neither does. Whether
await io(); await headers()throws should not depend on whether some other code calledafter()earlier in the same request.Note the error text is also misleading for this case: there is no
after()callback anywhere near the reported frame — the read is in the page body, which resumed after the response closed.Why this happens (source reading)
AfterContext.after()registers the current work-unit store inworkUnitStores(packages/next/src/server/after/after-context.ts).onClosehandler flipsphase = 'after'only for registered stores — the code carries this TODO:isRequestApiAllowedInCurrentPhase(packages/next/src/server/request/utils.ts), which rejects phase'after'on pages → E1378.So the throw requires an arming step (any
after()call earlier in the request) plus a race (response closed before the continuation ran). Identical code is legal or throwing depending on both.Real-world impact
In our production app the arming call was a session sliding-window helper inside
auth()— i.e. every authenticated request is armed. The result was a steady stream of E1378 noise on client aborts (and, withcacheComponents+ runtime prefetches, on prefetch responses closing before uncached IO settles), spread across every route, with stack frames pointing at innocentawait headers()lines far from anyafter(). It took decoding minified prod frames to connect the error to theafter()call in the auth helper, because nothing in the message or stack mentions the arming site.Suggested directions
The current middle state — throws only if armed, only on a close race — is the hardest version to operate: it cannot be caught deterministically in dev and surfaces as unattributable prod noise. Either direction would fix that:
after()page.headers()/cookies()called in a render's after phase resolve the already-captured request values — they are fixed at request start and the store still holds them — reserving the error forafter()callbacks proper, where the message is accurate.Meanwhile we work around it by pinning request-API calls before the first await (
const p = headers(); await io; const h = await p) and enforcing that with a custom ESLint rule — workable, but it encodes an internal race semantics apps shouldn't have to know about.Provide environment information
Also reproduced on a production deployment (Node standalone, Azure Container Apps).
Which area(s) are affected? (Select all that apply)
Runtime APIs (
headers/cookies),after()Which stage(s) are affected? (Select all that apply)
next start (local), Deployed (production), next dev