Skip to content

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

Description

@himself65

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions