The heart breaking inadequacy of AbortController

Charles Lowell
August 4, 2025

What is it about AbortController
and its companion AbortSignal
that keeps
developers from reaching for it while designing their APIs? If it’s the official
method of cancelling operations, then why don’t we see more of it in the wild?
It’s hard to pinpoint any one thing that’s wrong about it. The API is simple
enough and easy to understand. It builds on existing art that is widespread and
well understood in the form of EventTarget
, and yet its usage remains more the
exception than the rule. I believe that this is because it is not only
cumbersome and fragile, but also that it lacks fundamental capabilities that are
required for a cancellation primitive.
AbortController.abort() is an aspiration, not a constraint.
Sure, you can pass an AbortSignal
to an async function but you must
not only trust that it uses it correctly (more on what this means
later…), but also that it properly passes the signal along to any of
the async functions that it calls. And, each of those functions
must in turn pass the signal to each of the async functions that
they call; and so on and so forth down an unbounded and
exponentially expanding call stack. Any breaking of the
signal passing chain whatsoever, be it in your application code or a
3rd party library and boom! You’re stuck beyond the await event
horizon.
A tree of asynchronous function calls cannot forget to pass the abort
signal down to every operation, otherwise it risks becoming stuck
forever; with every single await
becoming a possible trap.
Given the conspicuous non-presence of signal passing in JavaScript code everywhere, this outcome is more a mathematical certainty than anything else. But even if you could find a way to thread an abort signal through every single async function call in your codebase, what does using it correctly mean anyway? There is no consensus.
One common way is to race the abort signal against every piece of asynchronous
work that the function does, and raise an error in the event of cancellation.
This presents an API similar to fetch()
and other platform apis that either
return a value or raise an AbortError
if cancelled.
async function work(signal) {
let aborted = new Promise((_, reject) => {
signal.addEventListener("abort", () => reject(new Error("AbortError"));
});
await Promise.race([doSomething(signal), aborted]);
await Promise.race([doSomethingElse(signal), aborted]);
return await Promise.race([doAnotherThing(signal), aborted]);
}
This works, but the sheer noise of it is enough to make most developers shrug and not bother.
However, it isn’t just writing such functions that pose a problem, consuming
them is also an issue. For example, what if you actually want to handle errors
in your application (which is something that most of us end up wanting to do at
some point). Well, because cancellation is shoe-horned into the call stack as
an error, you have to add boilerplate to every single catch {}
statement in
the application in order to propagate the cancellation properly.
try {
return await work(signal);
} catch (error) {
if (error.name === "AbortError") {
throw error; //propagate cancellation
}
console.log(`error doing work: ${error}`);
}
If we fail to do this even once in any of our try/catch
, then cancellation is stopped dead in its
tracks. Few would know to do this, and those that would don't because
it's tedious, and so nobody does unless they're specifically working
around a cancellation bug.
In fact, a GitHub search for the snippet
if (error.name === "AbortError")
shows that this is a very common complexity added to your workday catch block.
There are many other ways to a consume an abort signal that I won’t go into such as wrapping every abortable function’s result in a “maybe value”. The point though is that there is no single way, and there never will be, and so functions written with one set of abort signal conventions will not be composable with functions using with another.
Shutdown is part of the computation
Perhaps the greatest shortcoming of all is that controller.abort()
is a
synchronous function that requests a cancellation, but it does not provide any
mechanism to detect when and how the cancellation completed. This makes it
exceedingly difficult to make programming decisions based on what happens when
you abort an operation.
For example, suppose you want to write a simple supervisor that periodically restarts a set of three services every two minutes. In order to be correct, you can’t start the next set of three until the old set of three have been completely shutdown. Otherwise, you might leave file handles open, server ports still bound, or any other kind of resource leakage. To do that, we’d like to be able to “await” the outcome of the cancellation.
async function main() {
while (true) {
let controller = new AbortController();
let { signal } = controller;
await startServiceOne(signal),
await startServiceTwo(signal),
await startServiceThree(signal),
await sleep(120_000);
/* Alas, if only we could `await` cancellation. However, we cannot */
await controller.abort();
}
}
Not only would our supervision loop resume at just the right time, but also if there is a problem with the teardown itself, an error would be raised right at the moment of cancellation which we must either handle, or allow to propagate before attempting another turn of the loop.
The bad news is of course, that no matter how much we wish abort controllers worked this way, they don't. Instead of being a general tool for coordinating shutdown, they are nothing more than a channel that communicates an intent to do so. And when we use them, we are forced to muddle through the actual work of an orderly cancellation and hope that all the functions we pass our signal too can do the same. It should go without saying however that robust APIs are not built on hope.
There is some good news though: You don’t have to deal with the headaches of an abort controller at all when you have a structured concurrency library like Effection on your side. That’s because the lifetime of an operation is not determined by a mutable object like an abort signal, but is instead determined by the lexical structure of your program. So the above example could be written much more clearly as:
function* main() {
while (true) {
yield* scoped(function* () {
yield* startServiceOne(),
yield* startServiceTwo(),
yield* startServiceThree(),
yield* sleep(120_000);
});
}
}
The key here is that the scoped()
function declares that anything started
inside its body needs to be shut down before it can return… which means that you
get a fresh set of services with each turn of the loop. No awkward apis, no
wobbly signal passing, just program execution mirroring program text.
Structured Concurrency for the win
AbortController
seems like a reasonable interface at first. It is
simple, easy to understand, and it follows the established pattern of
all the other event based APIs already present in the ecosystem. Its
fatal flaw however, is that it arrived outside the context of the
actual problem that needs solving: that concurrent tasks, just like
local variables, should have their lifetimes constrained by the
lexical scope in which they appear. This fundamental misalignment
is the reason that AbortController
has not, and will not emerge as a
general solution to the problem of cancellation. If it was going to,
it would have already, and so it will remain a persistent
disappointment to all those attempting to use it in anger.
So I'll end by saying that if you've ever been frustrated by manually managing cancellation, I'd invite you to give Effection, or any other structured concurrency library a try. Zero effort cancellation? Feels great man.