skip to content

Async Patterns and Pitfalls

The production-grade layer: wrapping callback APIs, cancelling work, retrying with timeouts, and never dropping a rejection on the floor. Interviewers ask for these because they separate people who have shipped async code from people who have only read about it.

part ofJavaScriptoverview, primer and where to startread it →
on this pageshow

explore

questions

19

In JavaScript, how do AbortController and AbortSignal work together to cancel an in-flight asynchronous operation?

level: juniorimportance: must knowfreq 62%

answer

  1. one trigger, one observer
  2. the signal is the read-only half
  3. a flag, a reason, and an event
  4. operations must opt in
  5. one-way latch, second call is a no-op

basics

~20 s

AbortController 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 s

They 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 lines
javascript
const 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

for a junior

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.

for a middle

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.

for a senior

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.

for a principal

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

context

open as a page

In Node, fs.readFile(path, encoding, callback) reports its result through an error-first callback (err, data). How would you wrap that call so it returns a Promise instead, and what rules keep such a wrapper correct?

level: juniorimportance: must knowfreq 70%

basics

~10 s

Call the original inside new Promise and translate its callback: reject(err) when the first argument is truthy, otherwise resolve(data). The executor runs immediately, must settle exactly once, and should contain nothing but that translation.

open as a page

A JavaScript helper adds a timeout by racing an operation against a rejecting timer: `const timeout = (ms) => new Promise((_, reject) => setTimeout(() => reject(new Error('timed out')), ms)); const data = await Promise.race([slowRequest(), timeout(1000)]);`. If slowRequest() takes 5 seconds, what does the caller observe, and what happens to slowRequest() itself?

level: juniorimportance: must knowfreq 68%

basics

~20 s

The await rejects after one second with the timer's error, but slowRequest() keeps running to completion and its result is simply discarded. Promise.race only reports the first settlement; it cannot stop or unsubscribe from the loser.

open as a page

In JavaScript, what is a "floating promise", and what happens if an async function you called without awaiting it ends up rejecting?

level: juniorimportance: must knowfreq 75%

basics

~20 s

A floating promise is one whose result nobody awaits or attaches a handler to. If it rejects, the error never reaches the caller's try/catch: browsers fire an unhandledrejection event and log it, and Node terminates the process by default.

open as a page

Why does JavaScript have no promise.cancel(), and what does AbortController give you instead?

level: middleimportance: must knowfreq 58%

basics

~20 s

A promise is a read-only handle to a result that only its creator can settle, and any number of consumers may hold it, so no consumer can be allowed to cancel it. JavaScript therefore signals cancellation out-of-band with AbortController.

open as a page

Write a JavaScript `retry(fn, { attempts, baseDelay })` helper that calls an async function up to N times with growing delays between attempts. What makes the delay and the sequencing actually work, and how does the final failure reach the caller?

level: middleimportance: must knowfreq 60%

basics

~20 s

Write an async function around a for loop: try { return await fn(); }, and in the catch remember the error, await a promise-wrapped setTimeout for the growing delay, then loop. After the last attempt, throw the remembered error so total failure is not swallowed.

open as a page

In JavaScript, when work is cancelled through an AbortSignal, what error surfaces, and how do you tell that cancellation apart from a genuine failure inside a catch block?

level: middleimportance: should knowfreq 48%

basics

~20 s

The rejection value is signal.reason: a DOMException named "AbortError" by default, or whatever value you passed to abort(). Distinguish cancellation by checking err.name === 'AbortError' or comparing the error to signal.reason — never by matching the message text.

open as a page

Review this JavaScript, where db.query(id) already returns a promise: function getUser(id) { return new Promise((resolve, reject) => { db.query(id).then(row => resolve(row), err => reject(err)); }); } — what is wrong with it, and when is new Promise actually the right tool?

level: middleimportance: should knowfreq 55%

basics

~20 s

This is the explicit promise construction antipattern: db.query already returns a promise, so the wrapper adds a layer that can only lose errors. Return db.query(id) directly. Reserve new Promise for adapting non-promise sources such as callbacks, events, or timers.

open as a page

In Node, what contract must a function satisfy for util.promisify(fn) to produce a working promise version, and what is the util.promisify.custom symbol for?

level: middleimportance: should knowfreq 40%

basics

~20 s

util.promisify expects a function whose last parameter is a callback invoked as (err, value); it returns a version that resolves with value or rejects with err. Functions that break that convention supply their own implementation under the util.promisify.custom symbol.

open as a page

A JavaScript retry helper is written as `retry(operation, 3)`. Why must `operation` be a function that returns a promise rather than an already-created promise, and what exactly happens if someone calls `retry(loadUser(1), 3)`?

level: middleimportance: should knowfreq 45%

basics

~20 s

A promise represents work that already started and settles exactly once, so awaiting it again never re-runs anything. Passing loadUser(1) gives the helper one settled rejection to await three times: it burns the backoff delays and calls the dependency once.

open as a page

How does a program observe unhandled promise rejections globally in a browser versus in Node, and what does each runtime do by default when nothing handles a rejection?

level: middleimportance: should knowfreq 52%

basics

~20 s

Browsers fire an unhandledrejection event on the global object, carrying .promise and .reason, and log the error unless you call preventDefault(). Node emits process 'unhandledRejection' and, if no listener exists, raises it as an uncaught exception and exits non-zero.

open as a page

If a promise rejects and you attach a .catch() handler to it inside a setTimeout callback, is the rejection still reported as unhandled? Explain the timing rule the runtime uses.

level: middleimportance: should knowfreq 50%

basics

~20 s

Yes. Runtimes decide a rejection is unhandled once the current turn's queued jobs have finished running, so a handler attached in a later timer callback arrives too late: the unhandled rejection is reported first, then a rejectionhandled notification follows.

open as a page

You are writing your own promise-returning function that accepts an AbortSignal. What must it do to honour that signal correctly, and what goes wrong if you get it wrong?

level: seniorimportance: should knowfreq 42%

basics

~20 s

Check the signal before starting (signal.throwIfAborted()), subscribe to the abort event with once, reject with signal.reason and genuinely stop the underlying work, and remove the listener when the operation settles. Skipping the pre-check misses an already-aborted signal; skipping cleanup leaks listeners on long-lived signals.

open as a page

A Node service occasionally leaves a request hanging forever with nothing logged, and the code path runs through a hand-written new Promise wrapper around a callback API. What bugs in such a wrapper leave a promise permanently pending, and how would you track one down?

level: seniorimportance: should knowfreq 45%

basics

~20 s

A promise that is never settled produces no error and no timeout, so the awaiting handler stalls silently. The usual causes are a branch that calls neither resolve nor reject, a throw inside the callback after the executor has returned, an async executor whose rejection is discarded, and an error channel that was never wired up.

open as a page

In a JavaScript module, several parts of an app call `loadUser(id)` at nearly the same moment and each call fires its own request. How do you dedupe them by caching the in-flight promise, and what must the cache do when the operation rejects?

level: seniorimportance: should knowfreq 40%

basics

~20 s

Cache the promise, not the value: on the first call store fetchUser(id) in a Map keyed by id and hand that same promise to every later caller. Attach .finally(() => cache.delete(id)) so a rejection is never cached permanently.

open as a page

You start two async calls without awaiting them, then await them one after the other: `const a = fetchA(); const b = fetchB(); const ra = await a; const rb = await b;`. Why can this produce an unhandled rejection that kills a Node process, even though both promises are eventually awaited?

level: seniorimportance: should knowfreq 42%

basics

~20 s

Between the two awaits, promise b has no handler attached. If b rejects while execution is parked on await a, the runtime sees a handler-less rejection and reports it — in Node that terminates the process before the await b line ever runs.

open as a page

What does AbortSignal.any() do in JavaScript, and why would you use it instead of just forwarding the signal you were handed?

level: seniorimportance: nice to knowfreq 26%

basics

~20 s

AbortSignal.any(signals) returns a composite signal that aborts as soon as any input signal aborts, adopting that signal's reason. Use it when an operation must honour a caller's signal plus another condition — an internal deadline or a shutdown signal — without touching the caller's controller.

open as a page

In Node, a helper promisifies a one-shot operation on a long-lived EventEmitter by adding 'done' and 'error' listeners inside a new Promise. After the helper is called thousands of times on the same emitter, what breaks, and what is the fix?

level: seniorimportance: nice to knowfreq 33%

basics

~20 s

The promise settles once, but whichever listener did not fire stays attached forever, so each call leaks a closure and everything it captures. Node warns about a possible leak past ten listeners, emits grow slower, and heap climbs. Remove both listeners on settle, or use events.once.

open as a page

A long-running Node service hits an unhandled promise rejection in production. Should the process crash, as Node does by default, or should you install a handler that logs and keeps serving? How would you decide?

level: principalimportance: nice to knowfreq 28%

basics

~20 s

Default to crashing: an unhandled rejection means an error path nobody designed for, so the process state is unknown. Register a hook only to add context and shut down cleanly with a non-zero exit code, letting a supervisor restart a fresh process.

open as a page