vercel / vercel/next.js

Request APIs after an await throw E1378 on client disconnect — but only if `after()` was called earlier in the request

Open
#98,067 0 comments 0 reactions 0 assignees View on GitHub

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>;
}
  1. pnpm install && pnpm build && pnpm start
  2. 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
  3. 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)
  1. AfterContext.after() registers the current work-unit store in workUnitStores (packages/next/src/server/after/after-context.ts).
  2. 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

  3. 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:

  1. 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.
  2. 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

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.