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?
answer
- the rejection value is signal.reason
- default name, not default message
- AbortError versus TimeoutError
- identity check against your own signal
- expected outcome, not an incident
basics
~20 sThe 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.
solid answer
~50 sWhen an operation honours a signal it rejects with `signal.reason`. If you called `abort()` with no argument, that reason is a `DOMException` whose `name` is `"AbortError"`; if you called `abort(myError)`, the reason is exactly that value, unwrapped. A signal from `AbortSignal.timeout(ms)` aborts with a `DOMException` named `"TimeoutError"` instead, which is how you tell "my deadline expired" from "my caller cancelled". In a catch, the robust checks are `err?.name === 'AbortError'` or, when you own the controller, `err === signal.reason` / `signal.aborted`. Never match on the message string — it is not standardised across engines. The reason this matters is reporting: a cancellation is an expected outcome, so you swallow it or return quietly, while a real failure gets logged, retried, or shown to the user. Treating aborts as errors floods your monitoring with noise every time a user navigates away.
code
javascript · 19 linesconst c = new AbortController();
c.abort();
console.log(c.signal.reason.name); // "AbortError"
const custom = new AbortController();
custom.abort(new Error('user left the page'));
console.log(custom.signal.reason.name); // "Error" <- generic handlers will miss this
console.log(custom.signal.reason.message); // "user left the page"
try {
custom.signal.throwIfAborted();
} catch (err) {
console.log(err === custom.signal.reason); // true — thrown verbatim
}
// Keep the name so upstream cancellation checks still match:
const good = new AbortController();
good.abort(new DOMException('Navigation away', 'AbortError'));
console.log(good.signal.reason.name); // "AbortError"go deeper
Recall that a cancelled operation rejects, and that the default rejection has name === 'AbortError'. Say that you check the name rather than the message.
Explain that the rejection value is signal.reason verbatim, that a custom reason is not wrapped, and that AbortSignal.timeout uses TimeoutError so a deadline is distinguishable from a caller cancelling.
Show the handling asymmetry in production terms: swallow cancellations, propagate failures, and keep aborts out of error budgets and retry paths. Mention the identity check when several layers can abort the same work.
Set the convention across services and libraries — what reason objects carry, how cancellation is classified in observability, and why cancelled requests must not count against reliability targets or trigger automated remediation.
## The rejection value is the reason There is no separate cancellation channel. A cooperating operation rejects its promise, and the rejection value is whatever `signal.reason` holds. That single rule explains every case: ```js const c = new AbortController(); c.abort(); console.log(c.signal.reason.name); // "AbortError" const custom = new AbortController(); custom.abort(new Error('user left the page')); console.log(custom.signal.reason.message); // "user left the page" ``` - `abort()` with no argument stores a `DOMException` whose `name` is `"AbortError"`. This is the default the whole ecosystem keys off. - `abort(value)` stores `value` verbatim. It is not wrapped, not coerced, not copied. If you pass a string, the promise rejects with a string. - `signal.throwIfAborted()` throws that same value, so a pre-check inside your own code produces the identical error a listener would. - `AbortSignal.timeout(ms)` aborts itself with a `DOMException` named `"TimeoutError"` — a deliberately different name, because a deadline expiring is a different event from a caller changing its mind. ## Detecting cancellation in catch Three checks, in rough order of preference: ```js try { await doWork(input, { signal }); } catch (err) { if (signal.aborted && err === signal.reason) { return; // definitely our cancellation } if (err?.name === 'AbortError') { return; // cancellation from a signal we may not own } throw err; // a real failure } ``` 1. **Identity against your own signal** (`err === signal.reason`) is the strongest test when you own the controller: it proves this rejection came from *your* cancellation and not from an unrelated abort deeper in the stack. 2. **`err?.name === 'AbortError'`** is the portable check. Use optional chaining, because a rejection value is not guaranteed to be an object at all — someone may have aborted with a string or `undefined`. 3. **`signal.aborted`** alone tells you a cancellation happened, but not that *this* error is it; a genuine failure can occur in the same window. Prefer it as a supporting check. What not to do: - **Do not match the message.** `"The operation was aborted"`, `"signal is aborted without reason"` and similar strings differ across engines and versions. Matching them is a latent bug. - **Do not rely on `err instanceof Error`** to classify. A custom reason can be any value, and class checks on host exception types are a poor discriminator compared with `name`. - **Do not rely on `instanceof DOMException`** if the value may have crossed a realm boundary such as a worker or iframe, where the constructor differs. ## Custom reasons Passing your own reason is genuinely useful when several things can cancel the same work: ```js controller.abort(new DOMException('Navigation', 'AbortError')); ``` Keeping `name` as `"AbortError"` means generic handlers up the stack still recognise it as a cancellation, while your `message` carries the local detail. A plain `new Error('cancelled')` breaks that: its `name` is `"Error"`, so every library that filters on `AbortError` will now report your cancellation as a failure. ## Why the distinction earns its keep Cancellation is an *expected* outcome of normal use — a user navigates away, a component unmounts, a newer request supersedes an older one. Failures are not. Conflating them produces concrete damage: - Error-reporting tools fill with `AbortError` noise, drowning real incidents and burning quota. - Retry logic re-runs work the caller explicitly asked to stop, which is at best waste and at worst a correctness bug for non-idempotent operations. - The UI shows "something went wrong" for an action the user themselves cancelled. The conventional handling is therefore asymmetric: swallow or quietly return on cancellation, and let everything else propagate. ## The finally trap A `finally` block runs on abort just as on success, so cleanup happens either way — but be careful about what you do there. Updating shared state or a cache from a `finally` after cancellation can resurrect work the caller cancelled; re-checking `signal.aborted` before committing anything is the safe pattern. ## Aborts still need handlers Because cancellation is delivered as a rejection, an aborted operation with no rejection handler is an unhandled rejection like any other. "It was only cancelled" is not an exemption — the fire-and-forget call still needs a `.catch`.
- Why is checking err.name preferable to checking the error message?`name` is the standardised discriminator — `"AbortError"` for a default abort, `"TimeoutError"` for `AbortSignal.timeout`. Messages are implementation detail and differ across engines and versions, so string matching silently breaks on an engine upgrade. `name` also survives values you did not create yourself, which is exactly the case in a catch block.
- If you abort with your own reason, what should that value be?A `DOMException` whose name is still `"AbortError"`, with your detail in the message, so generic handlers upstack keep recognising it as cancellation. A plain `new Error('cancelled')` has `name === 'Error'`, so every library filtering on `AbortError` will classify your cancellation as a genuine failure and report or retry it.
- How do you distinguish your own cancellation from an abort raised somewhere deeper in the call stack?Compare identities: `signal.aborted && err === signal.reason` proves the rejection carries *your* reason. A bare `name` check cannot tell the difference, so an inner component's abort would look like yours. Identity matters when the outer code should retry or surface an inner cancellation rather than silently swallow it.
- An abort happens and your finally block updates a shared cache. What is the risk?`finally` runs on the cancellation path too, so you can commit results for work the caller explicitly stopped — stale data written after a newer request already landed. Re-check `signal.aborted` before any state mutation in cleanup, and keep `finally` to releasing resources rather than publishing outcomes.
saying these in an interview costs you the question
- Matches on the error message string instead of name
- Treats every abort as a failure and reports it to monitoring
- Assumes abort always rejects with an instance of Error
- Aborts with a plain Error so upstream AbortError checks miss it
- Retries an operation that was deliberately cancelled