-
Notifications
You must be signed in to change notification settings - Fork 1
Description
The AbortSignal / Cancel Token–style abort protocol has long been criticized for its ergonomics. This concern was raised as early as 2016, when the initial cancelable promises proposal was presented to the working group. In the meeting notes, Yehuda Katz, Mark Miller, and Waldemar Horwat all pointed out usability concerns (May 25, 2016 notes). Kevin Smith, who originally drafted the proposal, also acknowledged ergonomic shortcomings in subsequent discussions (zenparsing/es-cancel-token#3, zenparsing/es-cancel-token#2). Domenic Denicola likewise recognized these issues in his presentation materials.
I would like to highlight a different ergonomic problem that has received comparatively little attention: the leaky behavior of abort signal handlers.
Consider the following example from MDN:
function myCoolPromiseAPI(/* …, */ { signal }) {
return new Promise((resolve, reject) => {
// If the signal is already aborted, immediately throw in order to reject the promise.
signal.throwIfAborted();
// Perform the main purpose of the API
// Call resolve(result) when done.
// Watch for 'abort' signals
// Passing `once: true` ensures the Promise can be garbage collected after abort is called
signal.addEventListener(
"abort",
() => {
// Stop the main operation
// Reject the promise with the abort reason.
reject(signal.reason);
},
{ once: true },
);
});
}In this example, the asynchronous operation registers an abort event listener but never explicitly removes it. If the signal is never aborted, the listener remains attached indefinitely. This results in a retained closure and, depending on the structure of the surrounding code, can cause memory to be retained longer than intended.
Importantly, the { once: true } option only guarantees automatic removal after the abort event fires. It does not address the case where the operation completes successfully without the signal ever being triggered. In such cases, the handler persists for the lifetime of the signal.
A complete unit test demonstrating this retention behavior is available here:
https://stackblitz.com/edit/abortcontroller-leak-tests?file=AbortController.test.ts
This issue is not limited to trivial examples. The composition example in the current design discussion (see the “composition-any” section) also exhibits similar leakage characteristics: upstream handlers remain attached even if all downstream handlers are unsubscribed.
To gauge real-world practices, I conducted a non-comprehensive GitHub code search for addEventListener("abort", ...) in JavaScript and TypeScript codebases. Only a small minority of examples properly unregister the listener on successful completion. In practice, most implementations omit cleanup.
At present, AbortController is primarily used in a limited set of WHATWG interfaces, which somewhat mitigates the impact. However, if this protocol becomes a core language standard and is widely adopted across userland libraries, we should expect long-lived signals to propagate through deeply nested asynchronous call chains. In such environments, failure to systematically unregister handlers could lead to sustained resource retention and difficult-to-diagnose memory leaks.
Given that cancellation is fundamentally a control-flow concern, it is worth questioning whether a design that requires manual lifecycle management of event listeners is appropriate at the language level. If handler cleanup is routinely forgotten in practice, the abstraction may be too low-level for its intended role.
I believe this retention behavior deserves explicit consideration in evaluating the long-term viability of the current abort protocol design.