vercel / vercel/next.js

after(): a task that never settles retains the request's async context for the life of the server

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

Nobody has claimed this yet.

After Performance Route Handlers
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/mircoservices/next-after-waituntil-context-leak

To Reproduce
  1. npm install
  2. npm run build
  3. npm start — sets NODE_OPTIONS=--expose-gc so collection can be forced, which makes the measurement about retained memory rather than about garbage not yet collected.
  4. In a second shell, run the two controls first, then the case under test:
node measure.mjs settled 300     # after() with an already-settled promise
node measure.mjs detached 300    # a never-settling promise that is never given to after()
node measure.mjs leak 300        # after() with a never-settling promise

measure.mjs hits the route 300 times, then asks /api/heap to run global.gc() three times and report process.memoryUsage().

Each request puts 1 MiB into request-scoped async context (app/request-payload.ts), so the retention is visible as arrayBuffers. The three routes are otherwise identical. The route under test is three lines:

// app/api/leak/route.ts
export function GET() {
  return withPayload(() => {
    after(new Promise<void>(() => {})) // a background task that never settles
    return Response.json({ registered: 'never-settling promise' })
  })
}
Current vs. Expected behavior

Current. The 1 MiB belonging to every request that called after() with a task that does not settle is retained for the lifetime of the server process.

route what it does arrayBuffers retained
/api/settled after(Promise.resolve()) 0.0 MiB
/api/detached never-settling promise created, after() not called 0.0 MiB
/api/leak after(new Promise(() => {})) 300.0 MiB

Exactly 1 MiB per request, accumulating linearly — a second batch of 300 requests takes it from 300.1 MiB to 600.1 MiB — and never released, through any number of forced collections. The two controls rule out the obvious alternatives: settled shows it is not after() itself, detached shows it is not the unsettled promise on its own.

Expected. A task that has not finished should keep alive what it needs to run, and nothing else. Registering a task should not pin the async context of the request that registered it. The cost today is not the promise, it is everything reachable from the caller's context at the moment after() was called — in an App Router render, the request and work-unit store, which reference the RSC flight payload.

Where the retention comes from. AwaiterMulti.waitUntil drops a promise once it settles, which is what its comment describes. Nothing drops one that never settles, and the bookkeeping .then() captures the async context current at the call site and holds it for as long as the promise is unsettled:

// packages/next/src/server/after/awaiter.ts
public waitUntil = (promise: Promise<unknown>): void => {
  // if a promise settles before we await it, we should drop it --
  // storing them indefinitely could result in a memory leak.
  const cleanup = () => {
    this.promises.delete(promise)
  }

  promise.then(cleanup, (err) => {
    cleanup()
    this.onError(err)
  })

  this.promises.add(promise)
}

On a self-hosted server the awaiter that after() reaches lives as long as the process and only drains on shutdown, so there is no point at which the entry is released:

// packages/next/src/server/next-server.ts
createInternalWaitUntil() {
  // …
  const awaiter = new AwaiterOnce({ onError: console.error })
  // TODO(after): warn if the process exits before these are awaited
  this.onServerClose(() => awaiter.awaiting())
  return awaiter.waitUntil
}

Set membership is not what retains the context, the captured context is. Same 50-render harness, three variants of those two lines (awaiter-which-retainer.cjs in the reproduction, no Next install needed):

variant Set size contexts retained
current: Set entry + reaction in caller's context 50 50 / 50
Set entry, reaction registered in an empty context 50 0 / 50
reaction in caller's context, no Set entry 0 0 / 50
Provide environment information
Operating System:
  Platform: darwin
  Arch: arm64
  Available memory (MB): 131072
  Available CPU cores: 18
Binaries:
  Node: 24.21.0
  npm: 11.19.0
  pnpm: 10.33.0
Relevant Packages:
  next: 16.4.0-canary.26 // Latest available version is detected (16.4.0-canary.26).
  eslint-config-next: N/A
  react: 19.2.0
  react-dom: 19.2.0
  typescript: 5.9.2
Next.js Config:
  output: N/A
Which area(s) are affected? (Select all that apply)

After, Performance, Route Handlers

Which stage(s) are affected? (Select all that apply)

next start (local), Other (Deployed)

Reproduced on a self-hosted next start. Reading getWaitUntil() in base-server.ts, a Vercel deployment should not be affected: the platform's waitUntil from @next/request-context takes precedence, and the internal process-lifetime awaiter is only reached when that is absent and the server is not in minimal mode. I have not deployed the reproduction to confirm that.

Additional context

Verified against next@canary (16.4.0-canary.26), the latest available. packages/next/src/server/after/awaiter.ts is byte-identical on v16.2.12, v16.3.4 and canary, so this is not a regression.

Independent of Node version and of the AsyncLocalStorage implementation: identical results on Node 22.22.3, 24.18.0, 24.21.0 and 26.7.0, and with --no-async-context-frame.

The reproduction contains two routes that are not part of the bug, /api/semantics and /api/reject. They exist so a candidate fix can be checked for behaviour changes and are not needed to reproduce; leak, settled, detached and heap are.

The reproduction uses after() because it is the shortest public path to the awaiter. The way we hit this was background ISR revalidation, which registers a full re-render on the same process-lifetime awaiter:

// packages/next/src/server/response-cache/index.ts, in both get() and revalidate()
const promise = this.handleGet(/* … */)
// We need to ensure background revalidates are passed to waitUntil.
if (waitUntil) waitUntil(promise)
return promise

When such a re-render does not settle, the retainer chain from a heap snapshot was:

afterContext
  -> AwaiterOnce.awaiter (AwaiterMulti).promises
    -> a never-settling promise
      -> PromiseReaction  (registered by waitUntil)
        -> AsyncContextFrame
          -> flight request
            -> { flightData, segmentData }

roughly 1.4 MB per stale re-render, which is what led here. We have not isolated why that background render does not settle, so this issue is about the retention rather than the hang.

Possible fixes
  1. Register the bookkeeping reaction outside the caller's context. createSnapshot() in packages/next/src/server/app-render/async-local-storage.ts already does this with an edge-safe fallback, so it is a two-line change. One caveat worth a maintainer's judgement: it makes awaiter.ts depend on AsyncLocalStorage being on globalThis at import time, which async-local-storage.ts needs and which after-context.test.ts already calls a contortion to arrange in tests. It also moves onError out of the caller's context, which only affects tasks that reject, and those are exactly the ones that never leaked.
  2. Bound how long the awaiter tracks a promise, leaving the work itself running. Preserves error context, adds a timeout.
  3. Keep a WeakRef in the Set, though that changes what awaiting() can promise.

I have option 1 implemented with a unit test in awaiter.test.ts that fails on canary and passes with the change, for both AwaiterMulti and AwaiterOnce. Happy to open that PR, or a different one if you prefer another option.

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 packages/next/src/server/after/awaiter.ts and the related awaiter.test.ts mentioned in the issue, then run the reproduction's settled, detached, and leak measurements. Compare the AwaiterMulti and AwaiterOnce behavior and verify that the test and heap measurements show no request async context retained by a never-settling task while existing semantics remain intact.

Written by the indexing model from the issue text.

Assessment

Tech stack
next.js, node.js, typescript
Domain
api, backend, performance
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
52/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.