[react-server][dev] Clean up pendingOperations on request close to prevent dev memory leak#35801
[react-server][dev] Clean up pendingOperations on request close to prevent dev memory leak#35801alubbe wants to merge 3 commits intofacebook:mainfrom
Conversation
|
Hi @alubbe! Thank you for your pull request and welcome to our community. Action RequiredIn order to merge any pull request (code, docs, etc.), we require contributors to sign our Contributor License Agreement, and we don't seem to have one on file for you. ProcessIn order for us to review and merge your suggested changes, please sign at https://code.facebook.com/cla. If you are contributing on behalf of someone else (eg your employer), the individual CLA may not be sufficient and your employer may need to sign the corporate CLA. Once the CLA is signed, our tooling will perform checks and validations. Afterwards, the pull request will be tagged with If you have received this in error or have any questions, please contact us at cla@meta.com. Thanks! |
…event dev memory leak
|
Dear react team, is there anything else you require on this PR? I have also created a minimal reproduction (using nextjs) that you can use to verify the leak and the fix: https://github.com/alubbe/nextjs-dev-pending-operations-repro |
|
Thank you for opening this. We discussed this PR internally. We believe this fix is masking another memory leak. A heap snapshot only shows the reachable object i.e. excludes objects that are garbage-collectable. If you're still seeing objects in the
The reason we're not merging this as-is is that we're not sure clearing |
|
@alubbe Can you cleanup the repro to not include any manual memory tracking? I can use a memory profiler to validate. I want to make sure that the memory tracking isn't actually causing a leak. |
|
Thanks for the update, I had no idea it got seen. I'm on my phone right now, but I can share two things:
But when I get back I will clean the repro up as you say, just wanted to reply asap |
|
I've spent a few days trying to reproduce what we're seeing in our own app, and just couldn't. Here's what devtools shows - these async debug things just don't seem to get cleared up, but I don't know how to debug this further. Any tips? In trying to reproduce the above in https://github.com/alubbe/nextjs-dev-pending-operations-repro, I did however run into a few potential mem leaks. I say potential because maybe they are known/intended, or maybe my repro code is what leaks. Either way, I wanted to share them (tested with node 24.13.1 and pnpm 10.29.3): Potential mem Leak 1Here it seems as if memory usage grows just from calling the same action over and over, adding a lot of strings and compiled code to the heap. This may be intended for a dev server, though I don't understand why. To reproduce: Go to branch Potential mem Leak 2Here we get a lot of retained async debug stuff, and it's also doesn't get freed up by waiting or forcing GC, but when we call another action, the old stuff does get cleared up, new stuff gets created and now that hangs around. To reproduce: Go to branch Potential mem Leak 3Here we seem to get async debug leaks over time that don't get reclaimed. Might be the benchmark/repro code, but I can't tell. To reproduce: Go to branch ConclusionPlease let me know what the best of proceeding is.
|
|
I forgot to mention: all 3 branches (and our issue in our application) were all also tested against production mode, i.e. replacing |




Summary
This PR fixes unbounded growth of async debug tracking state in RSC dev mode by making cleanup deterministic and owner-aware, while keeping the debugging experience much closer to the original behavior.
Context
ReactFlightServerConfigDebugNodestores async lineage in a module-levelpendingOperations: Map<asyncId, AsyncSequence>.Historically, entries were mostly removed by
async_hooks.destroy, which is GC-timed and non-deterministic. In long-lived dev sessions, this can retain large amounts of async debug state and drive memory growth.In the application we're developing, we see multiple GB of memory usage after 10-15 minutes. A typical heapdump comparison looks like this:

Goals
Design
requestAsyncIds: WeakMap<Request, Set<number>>(request -> async IDs)asyncIdToRequests: Map<number, Set<Request>>(async ID -> owners)cleanedRequests: WeakSet<Request>(prevents re-tracking after cleanup)pendingOperationsglobally tracked, then attach ownership:resolveRequest()exists: add request ownership.triggerAsyncIdwhen applicable.initfrom creating new tracking nodes for requests alreadyCLOSING/CLOSEDor already cleaned.cleanupAsyncDebugInfo(request)marks request as cleaned, clearslastRanAwait, removes request ownership, and deletes apendingOperationsentry only when the last owner is removed.destroy(asyncId)markAsyncSequenceRootTask()ReactFlightServer:fatalErrorflushCompletedChunkscompletion/close branches (including “main closed, debug stream still open”)startFlowing/startFlowingDebugwhenCLOSING -> CLOSEDfinishHalt,finishAbort, and abort fast-path with no abortable tasksBehavior and Trade-offs
CLOSEDimmediately when main output is done but debug output remains open.Why this approach
pendingOperations.get(...)consumers).