So...what even is an AbortController?

Feb 18, 2026

learn-in-publicjavascript

I’ve fallen in love with a Web API, and its name is AbortController.

A few months ago, a colleague and I were knee-deep in a legacy codebase refactoring. We kept hitting the same wall: users would trigger a massive data fetch, get impatient, and navigate away, but the app would show the loading spinner, dutifully awaiting a 5,000-line JSON payload for a page that didn't even exist anymore.

As an app grows, your "quick" APIs inevitably get bloated. They start returning massive payloads and taking longer to resolve. Without a way to tell the browser, "Hey, I don't need this anymore," you’re essentially haunting your own UI with "zombie" requests that eat up bandwidth and memory.

Enter the AbortController

Think of it as the "Emergency Stop" button for your asynchronous operations. It allows you to communicate with an ongoing fetch request and shut it down the moment it becomes irrelevant.

Natively provided by JS API, the AbortController API works by creating an instance of the AbortController class. Once we have an instance, it exposes two things -

1. The `signal` property is an instance of the AbortSignal which can be passed to any API. The signal informs the function whether it should continue or abort.

2. The `.abort()` method which when called triggers any function attached to that signal to abort the call.

const controller = new AbortController()
const signal = controller.signal
async function fetchUsers() {
  const url = `/api/users`;
  try {
    const resp = await axios.get(url, {signal: this.signal});
    return resp;
  } catch {
    console.error("Something has gone wrong")
  }
}

With our usecase at work, we’ve limited it to `GET` requests but as we’ll see, it can be used with any API that supports the `signal` option.

Common Misconceptions

This is essential for understanding AbortControllers and how they “cancel” requests.

In Javascript, a Promise is a proxy for a value that will eventually exist. Once a Promise starts executing a piece of synchronous code, you cannot kill it from the outside.

When we call controller.abort() on a GET:

1. The JS Promise rejects immediately so the UI can move on.

2. The browser attempts to close the HTTP connection.

3. If the request has already reached the server, the server might still finish processing that heavy database query and send the data back. The browser just doesn’t care about the data and immediately tosses it in garbage collection.

When we speak of cancellation in a client-server setting, we must remember that cancellation is always cooperative. The client no longer caring about the data does not translate to the server no longer caring either – AbortController essentially just sends out an event that tells the code to move on but the server exists as a separate entity. This is true regardless of the language your BE is written in because at the base of it, AbortController is a WebAPI on your browser – it is not a language feature and only exists for your client.

To avoid computation of the function dependent on the response or just `abort` the function entirely, your code must check for signal.aborted and do an early return. JS implicitly does not handle this for you and continues to process the function.

Usage beyond GET

Event Listeners

Most people use `removeEventListener` which requires keeping a reference to the exact function. This gets messy in complex components. You will find yourself in state management hell passing around the listener as a prop between child/sibling components, especially when a different component is responsible for removing the listener.

You can pass a `signal` directly to `addEventListener`. Calling abort() once will instantly remove every listener attached to that signal.

const controller = new AbortController();

// Attach multiple unrelated listeners to one "kill switch"

window.addEventListener('resize', handleResize, { signal: controller.signal });

window.addEventListener('mousemove', handleMagic, { signal: controller.signal });

// Later, or on unmount:

controller.abort(); // Both listeners are instantly cleaned up.

Cancelling Multiple Operations at Once

When you are working with more than one abort signal, you can combine them using the AbortSignal.any(), similar to Promise.race() to handle multiple promises on a first-come-first-serve basis.

Imagine this, you have a time-sensitive prompt which can either be cancelled via user interaction or directly timeouts if not completed within 5 seconds.

// Create two separate controllers for different concerns
const userController = new AbortController();
const timeoutController = new AbortController();

// Set up a timeout that will abort after 5 seconds
setTimeout(() => timeoutController.abort(), 5000);

const combinedSignal = AbortSignal.any([userCancel.signal, timeoutSignal]);
try {
  await axios.get("/heavy-data", { signal: combinedSignal });
} catch (err) {
  if (err.name === "TimeoutError") console.log("Too slow!");
  if (err.name === "AbortError") console.log("User bailed!");
}

Abort Events After X Seconds

For any event that needs to be aborted after a certain timeout duration has passed, similar to our time-sensitive example from earlier, the AbortSignal API provides a static method `.timeout()` to create a signal that dispatches the abort event after a certain timeout duration has passed.

axios.get(url, {
 // Abort this request automatically if it takes
 // more than 3000ms to complete.
 signal: AbortSignal.timeout(3000),
})

Building Cancellation Trees

AbortController becomes significantly more powerful when you stop thinking of it as “a fetch thing” and start thinking of it as a cancellation primitive.

In real systems, work is rarely flat. It has structure:

  • A page load triggers multiple API calls
  • An API call triggers downstream requests
  • A user action starts several parallel operations

If the parent task becomes irrelevant (navigation, tab close, state change), all child work should stop.

This is where structured cancellation comes in.

You can propagate cancellation:

function createChildController(parentSignal) {
  const child = new AbortController();

  if (parentSignal.aborted) {
    child.abort(parentSignal.reason);
  } else {
    parentSignal.addEventListener(
      "abort",

      () => child.abort(parentSignal.reason),

      { once: true },
    );
  }

  return child;
}

What the above code does is essentially add a listener to the abort event at the Parent Component and if the AbortSignal is already aborted, we propagate the cancellation down.

Avoiding Memory Leaks

This took me a bit of time to wrap my head around. Not necessarily something you come across with a simple application but as your application grows and we run into more complex scenarios, you will deal with memory leaks leading to your application running slower and slower.

When researching this, I searched `memory leaks AbortSignal` and was bombarded with a bunch of GH Issues on Nodejs/Elastic/Claude Code as recent as 2025 so this still seems to be a black box when using AbortControllers for state management.

Under the hood, AbortSignal implements EventTarget. So when you write -

signal.addEventListener("abort", handler);

The signal holds a reference to handler. This does not automatically leak.

If the controller is short-lived with a defined scope, you don’t run into this problem. Issues arise when a signal is long-lived or reused.

This exact pattern triggered a real-world warning in the Node.js ecosystem and in the Anthropic Claude Code CLI (issue #2629).

Node emitted `MaxListenersExceededWarning: Possible EventTarget memory leak detected.` The same AbortSignal was reused across retried and each retry attached another listener to the same signal without removing the previous one.

const controller = new AbortController();

function retryingOperation() {
  controller.signal.addEventListener("abort", () => {
    cleanup();
  });

  doWork();
}

If retryingOperation() runs multiple times:

  • Each call adds a new listener
  • The signal is long-lived
  • Listeners accumulate
  • Node warns after 10

Node.js (Issue #55328) with v22.3.0 had issues around AbortSignal.any(). We learnt earlier that .any() works by combining signals – internally, this wires listeners between signals. So a single listener listens to all signals attached and forward aborts on any one signal aborting.

Imagine a structure →
signalA ---> listenerFn ---> controller ---> combinedSignal

If:

  • signalA is long-lived
  • And it has a listener referencing controller
  • And controller references combinedSignal

Then:

signalA indirectly keeps combinedSignal alive.

If you never remove the handler, combinedSignal will never be garbage collected despite the app dropping all references to it.

So what do you do?

We’ve not run into this situation at work yet but you should build a defensive pattern when using listeners and AbortSignals extensively.

signal.addEventListener(
  "abort",

  () => {
    cleanup();
  },

  { once: true },
);

A simple addition of {once: true} allows signals to be GC’ed post one use. This should be the intent.

Conclusion

The AbortController API is incredibly vast, extensive and extendible. You can create your own Abortable APIs and structure your code to fail fast avoiding the extra work that comes along the way. But AbortController should not be treated as a magical kill-switch. It will not roll back transactions or stop an ongoing mechanism. It is only a signaling mechanism to tell your application to stop caring about a certain result – how you use it can make it incredibly effectively.