Repository navigation
AbortSignal.any() causes memory leak #54614
Copy link
Copy link
Open
Labels
abortcontrollerIssues and PRs related to the AbortController and AbortSignal APIs.Issues and PRs related to the AbortController and AbortSignal APIs.memoryIssues and PRs related to Node.js memory management or memory footprint.Issues and PRs related to Node.js memory management or memory footprint.
Description
Version
v22.6.0
Platform
Microsoft Windows NT 10.0.22631.0 x64
Linux ****** 4.4.0-22621-Microsoft #3672-Microsoft Fri Jan 01 08:00:00 PST 2016 x86_64 x86_64 x86_64 GNU/Linux
Subsystem
https://nodejs.org/api/globals.html#class-abortsignal
What steps will reproduce the bug?
Run this and watch memory usage.
const formatMemoryUsage = (data) => `${Math.round(data / 1024 / 1024 * 100) / 100} MB`;
let memoryData = process.memoryUsage();
console.log('Mem before loop', formatMemoryUsage(memoryData.rss));
for (let i = 0; true; i++) {
const abortController = new AbortController();
const signal = abortController.signal;
const composedSignal = AbortSignal.any([signal]);
if (i === 1000000) {
break;
}
}
memoryData = process.memoryUsage();
console.log('Mem after 1 million iteration', formatMemoryUsage(memoryData.rss));This is what I get on my local machine

How often does it reproduce? Is there a required condition?
Always reproducible as far as I can tell
What is the expected behavior? Why is that the expected behavior?
Memory post loop execution should be fairly equivalent to the first log but somehow the const composedSignal = AbortSignal.any([signal]); does not get cleaned up from memory, I would expect this to get cleaned properly or if this is the intended behavior to have a clear warning in the documentation.
What do you see instead?
We see a memory leak that will eventually lead to an out of memory error.
Additional information
This has been tested on Node 22.6 on different machine and both Windows + Unix versions. Happy to provide more details if needed
Reacted by wille, Toni Villena, Dotan Simha, Davian99 and Paosder
Reacted by Dotan Simha, Davian99 and Paosder
Metadata
Metadata
Assignees
Labels
abortcontrollerIssues and PRs related to the AbortController and AbortSignal APIs.Issues and PRs related to the AbortController and AbortSignal APIs.memoryIssues and PRs related to Node.js memory management or memory footprint.Issues and PRs related to Node.js memory management or memory footprint.
Activity
kallerosenbaum commentedon Aug 28, 2024
This might (I'm not sure) be a duplicate of #48419
avivkeller commentedon Aug 28, 2024
geeksilva97 commentedon Sep 25, 2024
I started taking some snapshot
@redyetidev would you have any clue?
mika-fischer commentedon Oct 10, 2024
I think the issue here is that everything is happening in the same tick. This does not leak for me:
AlexGilleranGP commentedon May 29, 2025
I find that if you combine an abort signal inside the loop with one outside, you still get the problem although not quite as severely.
Even if the other abort signal is inside the loop it still ends up using more.
Weirdly I find the implementation in graphql-hive/gateway#922 resolves this in practical use, but in this test it doesn't seem to help much.
kallerosenbaum commentedon Sep 5, 2025
I've written a test file that demostrates various variations of this bug:
lodekeeper commentedon Mar 13, 2026
We’ve run into the same pattern in a real long-running production workload (Lodestar beacon-node req/resp networking).
In our case, each req/resp round-trip composed a request abort signal with a response-timeout signal. Over time, those composed signals accumulated in the networking worker and showed up as steady old-space growth in heap snapshots.
The important part is that this was not a tight same-tick synthetic loop — it was normal long-lived request/response traffic on a running node.
Our mitigation on the application side was to stop using
AbortSignal.any(...)for that path and instead manually compose the signals with explicit listener cleanup once the response stream closes. That change is here:So from our side, this issue is not just microbenchmark-only — we hit the same retention shape in a real networking path.
docs(reqresp): add upstream issue reference to createRespSignal
docs(reqresp): add upstream issue reference to createRespSignal (#9033)
khalidsaidi commentedon Mar 25, 2026
lib: defer AbortSignal.any() following
fix(webapp): named sentinel abort reasons + cite Node issues
fix(webapp): eliminate SSE abort-signal memory leak (#3430)
chore: release v4.4.5 (#3406)
lib: defer AbortSignal.any() following
lib: defer AbortSignal.any() following
adiwajshing commentedon May 14, 2026
Seems node 26.1.0 does not have this issue, similar script to @AlexGilleranGP
On Node 26.1.0:
On Node 25.9.0:
DmitryGolovin-azur commentedon Jul 28, 2026
Hi, guys. Here is our fix for this problem:
Note: not needed on Node 26 — we confirmed the leak is already gone there.
lodekeeper commentedon Jul 28, 2026
Thanks for sharing this. From the Lodestar side, this does not change our current mitigation yet.
We still target Node
^24.13.0, so we need the application-side workaround until our runtime baseline moves to a Node version with the fix. In the req/resp path that hit this in production, Lodestar already stopped usingAbortSignal.any(...)and now composes the parent signal + timeout signal with explicit listener cleanup when the response stream closes:Your WeakRef / FinalizationRegistry approach looks useful as a drop-in
any()workaround for callers that cannot deterministically know when the composed signal is no longer needed. For our req/resp case, the explicitclear()is still simpler because stream close gives us an exact cleanup point, and I would avoid monkey-patching globalAbortSignal.anyin Lodestar.The Node 26 confirmation is useful, though. Once Lodestar's minimum runtime moves past the fixed Node line, this workaround should become removable rather than something we need to replace with a weak-listener polyfill.
kallerosenbaum commentedon Aug 21, 2026
See #54614 (comment) for the code.
The first of these tests works on 26.7 (it fails on 24.15), the rest still succeeds/fails as indicated in the code.