after(): a task that never settles retains the request's async context for the life of the server
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/mircoservices/next-after-waituntil-context-leak
To Reproduce
npm installnpm run buildnpm start— setsNODE_OPTIONS=--expose-gcso collection can be forced, which makes the measurement about retained memory rather than about garbage not yet collected.- 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
- Register the bookkeeping reaction outside the caller's context.
createSnapshot()inpackages/next/src/server/app-render/async-local-storage.tsalready does this with an edge-safe fallback, so it is a two-line change. One caveat worth a maintainer's judgement: it makesawaiter.tsdepend onAsyncLocalStoragebeing onglobalThisat import time, whichasync-local-storage.tsneeds and whichafter-context.test.tsalready calls a contortion to arrange in tests. It also movesonErrorout of the caller's context, which only affects tasks that reject, and those are exactly the ones that never leaked. - Bound how long the awaiter tracks a promise, leaving the work itself running. Preserves error context, adds a timeout.
- Keep a
WeakRefin the Set, though that changes whatawaiting()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
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 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