In JavaScript, how do AbortController and AbortSignal work together to cancel an in-flight asynchronous operation?
answer
- one trigger, one observer
- the signal is the read-only half
- a flag, a reason, and an event
- operations must opt in
- one-way latch, second call is a no-op
basics
~20 sAbortController holds the trigger and exposes a read-only signal you hand to an operation. Calling controller.abort(reason) sets signal.aborted to true, stores signal.reason, and synchronously fires the signal's abort event, so any operation watching that signal can stop.
solid answer
~40 sThey are the two halves of one cancellation channel. You create a `new AbortController()`, keep the controller yourself, and pass `controller.signal` — a read-only `AbortSignal` — into whatever operation you might want to stop. When you call `controller.abort(reason)`, three things happen at once: `signal.aborted` flips to `true`, `signal.reason` is set to the value you passed (or a default `DOMException` named `AbortError` if you passed nothing), and an `abort` event is dispatched synchronously on the signal. Anything holding the signal reacts by checking `signal.aborted`, calling `signal.throwIfAborted()`, or listening for that event. The split matters: only the controller can trigger cancellation, while consumers get an observe-only handle. One signal can be given to many operations, so a single `abort()` tears all of them down together. Abort is one-way and idempotent — a second `abort()` does nothing.
code
javascript · 17 linesconst controller = new AbortController();
const { signal } = controller;
signal.addEventListener('abort', () => {
console.log('stopping because:', signal.reason);
}, { once: true });
console.log(signal.aborted); // false
controller.abort('user navigated away');
// listener has already run by this line
console.log(signal.aborted); // true
console.log(signal.reason); // 'user navigated away'
controller.abort('again'); // no-op: no second event, reason unchanged
console.log(signal.reason); // 'user navigated away'go deeper
Know the shape by heart: create a controller, pass controller.signal into the operation, call controller.abort() to cancel. Be able to name signal.aborted and signal.reason without hesitation.
Explain why the API is split in two — the controller can trigger, the signal can only observe — and describe exactly what abort() mutates and dispatches, including that the event fires synchronously and only once.
Show where the controller lives in a real system: one per request or component lifetime, aborted in a teardown path, with the same signal fanned out to every operation started in that scope. Say plainly that cancellation is cooperative and only affects code that opted in.
Own the convention across a codebase: which layers accept a { signal } option, whether cancellation propagates through your internal APIs at all, and the cost of a codebase where half the async surface silently ignores signals.
## The problem it solves JavaScript's asynchronous primitives — promises and async functions — describe *results*, not *operations*. Once you have a promise, you have a handle to a value that will arrive; there is nothing on it that says "stop doing the work behind this". `AbortController` fills that gap with a small, standard, out-of-band signalling channel that any API can choose to accept. It is not part of ECMAScript itself — it comes from the WHATWG DOM standard — but it is available as a global in modern browsers and in Node, and it is the answer interviewers expect to "how do you cancel an in-flight operation?". ## The two halves ```js const controller = new AbortController(); const signal = controller.signal; // an AbortSignal ``` - **`AbortController`** is the *trigger* side. It has exactly one method, `abort(reason)`, and one property, `signal`. You keep the controller in the code that decides when to give up. - **`AbortSignal`** is the *observe* side. It has no `abort()` method — a consumer that holds a signal can watch cancellation but never cause it. That asymmetry is the whole design: you can safely hand a signal to library code without giving it the power to cancel your work. The signal exposes: - `signal.aborted` — a boolean, `false` until aborted, then permanently `true`. - `signal.reason` — `undefined` until aborted, then whatever value was passed to `abort()`. - `signal.throwIfAborted()` — throws `signal.reason` if aborted, otherwise returns quietly. - an `abort` event, since `AbortSignal` is an `EventTarget`; you can use `addEventListener('abort', fn)` or the `signal.onabort` property. ## What abort() actually does ```js const controller = new AbortController(); const { signal } = controller; signal.addEventListener('abort', () => { console.log('stopping because:', signal.reason); }, { once: true }); controller.abort('user navigated away'); console.log(signal.aborted); // true console.log(signal.reason); // 'user navigated away' ``` The state change and the event dispatch happen **synchronously** inside the `abort()` call — by the time `abort()` returns, every `abort` listener has already run. The event fires at most once, and the state is a one-way latch: there is no `reset()`, no un-abort. Calling `controller.abort()` again after the signal is aborted is a silent no-op, which is why abort-on-cleanup code can run unconditionally without guards. If you call `abort()` with no argument, the reason defaults to a `DOMException` whose `name` is `"AbortError"`. If you pass your own value — a string, an `Error`, an object — it is stored verbatim and rethrown verbatim by `throwIfAborted()`. ## Opting in Nothing is cancelled automatically. An operation is abortable only because its author wrote code that looks at the signal: checking `aborted` at the start, subscribing to the `abort` event, and rejecting the promise with `signal.reason` when it fires. That is why the standard convention is an options bag — `doWork(input, { signal })` — and why an API that doesn't document a `signal` option will simply ignore one you pass. ## Pre-made signals Two static helpers produce signals without a controller: ```js AbortSignal.abort('too late'); // already-aborted signal, useful in tests and fast paths AbortSignal.timeout(5000); // aborts itself after 5s, reason is a TimeoutError DOMException ``` Because neither hands you a controller, nothing else can abort them; they abort on their own terms. ## One controller, many operations A single signal can be passed to any number of operations. That is the idiomatic teardown pattern: one controller per component, request, or page view; every operation started in that scope receives the same signal; one `abort()` call at teardown time stops the whole group. The flip side is granularity — if you need to cancel just one of them, that one needs its own controller. ## What it does not do `abort()` signals intent; it does not preempt anything. Synchronous code already running keeps running to completion, side effects already committed stay committed, and an operation that never looks at the signal is unaffected. Cancellation in JavaScript is cooperative by construction, and `AbortController` is only the notification channel.
- What happens if you call controller.abort() twice, or abort after the operation has already finished?Both are harmless no-ops. The signal is a one-way latch: once `aborted` is `true`, the reason is fixed and the `abort` event never fires again. Aborting after completion changes nothing, because the operation already settled and stopped listening. This is why teardown code can call `abort()` unconditionally without checking state first.
- Can one controller cancel several operations at once, and what is the downside?Yes — pass the same `signal` to every operation and a single `abort()` fires one event that all of them observe, sharing the same `reason`. That is the standard scope-teardown pattern. The downside is granularity: the signal is all-or-nothing, so cancelling one operation individually requires giving it its own controller, or composing signals.
- How do you get a signal that is already aborted, without creating a controller?`AbortSignal.abort(reason)` returns a signal whose `aborted` is already `true` and whose `reason` is the value you passed, or a default `AbortError` `DOMException`. It is useful in tests, and in fast paths where you want to hand a callee a signal that makes it bail out immediately rather than start work at all.
saying these in an interview costs you the question
- Thinks abort() returns a promise you await
- Says abort() kills the running code immediately
- Passes the controller itself instead of controller.signal
- Believes every async API honours a signal automatically
- Expects the signal to reset so it can be reused