Request APIs after an await throw E1378 on client disconnect — but only if `after()` was called earlier in the request
Nobody has claimed this yet.
- Dominant language
- JavaScript
- Stars
- 142k
- Forks
- 32.4k
- Avg merge
- 2d 14h
- Merged PRs (30d)
- 351
Description
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/armedandcurl --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 inworkUnitStores(packages/next/src/server/after/after-context.ts).- The
onClosehandler flipsphase = 'after'only for registered stores — the code carries this TODO:TODO(after): it's not ideal that we'll only switch the
phaseof a WorkUnitStore ifafter()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 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
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
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with the linked reproduction, then read packages/next/src/server/after/after-context.ts and packages/next/src/server/request/utils.ts, especially the close-phase handling and request API checks. Reproduce the armed and unarmed routes with next start and verify that the chosen contract makes their post-disconnect behavior consistent and that any error identifies the actual context accurately.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- next.js, typescript
- Domain
- api, backend
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 52/100