AbortController.abort() Doesn't Mean It Stopped

Taras Mankovski
February 13, 2026
The false promise
You called abort(), it returned, and then the logs kept coming, the socket
stayed open, or the process still owned the port after Ctrl-C. This is the trap:
AbortController.abort() looks like shutdown, but it is only a signal. It tells
listeners to begin cancellation work; it does not tell you that work finished.
If one layer ignores the signal, or handles it partially, work keeps running
after the caller believes the task is over. abort() is a request, not a
guarantee, and that gap is where orphaned work comes from.
The leak
Here's what a hidden leak looks like: you call abort(), it returns, the
promise rejects, the caller awaits completion — but the interval keeps ticking
forever.
(async () => {
const controller = new AbortController();
const done = task(controller.signal).catch((e) => {
console.log("task ended with:", e.message);
});
setTimeout(() => {
console.log(">>> calling abort()");
controller.abort();
console.log(">>> abort() returned");
}, 700);
await done;
console.log(">>> caller thinks everything is done");
// But "tick: STILL RUNNING" continues forever
})();
async function task(signal) {
// Orphaned: no cancellation boundary
setInterval(() => console.log("tick: STILL RUNNING"), 200);
await new Promise((_, reject) => {
signal.addEventListener("abort", () => reject(new Error("aborted")), {
once: true,
});
});
}
The interval is never tied to the signal. When abort() fires, the promise
rejects, the task appears to end, but the timer survives. From the call site,
lifecycle looks complete. From the runtime, it's not. This is how leaks hide in
plain sight: the code that initiated cancellation has no direct way to confirm
shutdown actually finished.
Why this happens
abort() dispatches an event and returns. The signal is synchronous; the
consequences are not — and the platform provides no primitive to wait for those
consequences to finish. Teardown happens in listener code spread across your
stack, depending on each function honoring the signal and forwarding it to
whatever it calls. Miss it once and the chain breaks.
This is not a flaw in AbortController's design so much as a limit of what cancellation can express without structured lifetimes. AbortController propagates intent; structured concurrency propagates ownership. Intent is advisory: every layer must cooperate. Ownership is structural: the parent scope ensures children cannot outlive it. Correctness should not depend on discipline across an unbounded stack.
Structured lifetimes in practice
In structured concurrency, a child cannot outlive its parent. When scope exits, child work is canceled and awaited before control continues. Cleanup is guaranteed unless you opt out.
Here's the same work shape, but with structural ownership:
import { main, scoped, sleep, spawn } from "effection";
await main(function* () {
yield* scoped(function* () {
yield* spawn(function* ticker() {
while (true) {
console.log("tick: RUNNING");
yield* sleep(200);
}
});
yield* sleep(700);
console.log(">>> leaving scope");
});
console.log(">>> scope exited; all children are stopped");
});
When the scoped block exits, the ticker is halted and fully unwound before the next line runs. No manual signal forwarding. No hidden background survivors.
The same applies to real resources: fetch requests, WebSockets, child processes. In Effection, resource operations are written as generators that tie cleanup to scope exit — so a fetch that never completes gets aborted when the parent scope closes, a WebSocket closes its connection, a process is killed. The pattern is the same: scope exit = guaranteed shutdown.
Close
Structured lifetimes change the default: cleanup becomes automatic, and leaking becomes the thing you have to go out of your way to do. Effection delivers this for JavaScript — seven years in production, from trading platforms to CLI tools. For the full technical critique of AbortController, see The Heartbreaking Inadequacy of AbortController.