Abort Signal Reason Composition and Late Listeners
One probe on abort reasons: what the default reason is, how timeout and any pick one, how often the event fires, and why a late listener never runs.
BotBrowser Team
Want the structured docs for Fingerprint?
This article lives in the editorial library. For step-by-step setup, reference material, and ongoing updates, jump into the docs section.
BotBrowser supports replaying an authorized same-origin fixture under a declared profile and keeping a bounded record of which cancellation paths a page actually reaches, but it cannot keep a listener that was attached after the abort from being skipped, cannot rewrite a non-Error reason into an Error, and cannot make two composed signals report two reasons. This review runs one probe over twelve fixed cases and records what signal.reason holds before and after an abort, which DOMException name each built-in source produces, how AbortSignal.timeout() and AbortSignal.any() choose among several causes, how many times the abort event fires when abort() is called twice, what happens to a listener added after the fact, and the identity rule behind throwIfAborted().
TL;DR
- Before any abort, the
reasonproperty already exists and reading it givesundefined, so a test for the property's presence never proves that an abort happened. - Calling
abort()with no argument stores aDOMExceptionwhose constructor isDOMExceptionand whosenameisAbortError, with the messageThis operation was aborted. - An explicit reason is kept by identity, so the object the canceller passed is the same object a catch block receives.
- A reason does not have to be an
Error: passing42leaves the signal aborted with a number, and later reads ofreason.messageorreason.namefind nothing. AbortSignal.timeout(15)starts un-aborted and, when the timer fires, reports aDOMExceptionnamedTimeoutError, notAbortError.AbortSignal.any([a, b])forwards the winning member's reason unchanged, and the member that aborts first wins regardless of its position in the array.- A composite built from an already aborted member is aborted during the
any()call itself, because members are inspected synchronously. - Two
abort()calls on one controller produce exactly one abort event and keep the first reason. - A listener attached after the abort never runs, and neither does
signal.onabort, even thoughsignal.abortedis alreadytrue. - The abort event is a plain
Eventwith noreasonfield, so the handler has to readsignal.reasoninstead of its argument. throwIfAborted()rethrows the stored reason by identity, which means the canceller's own error object reaches the caller's catch block unchanged.
Contents
- What reason holds before and after an abort
- How timeout and any pick a single reason
- How many times the abort event fires
- Why throwIfAborted rethrows the same object
- Record an abort reading
What reason holds before and after an abort
Cancellation in the web platform is a signal that carries one value, and that value is the only thing a consumer can inspect after the fact. The probe starts by looking at a fresh controller:
node=v26.9.0
initial -> aborted=false hasReason=true
default-reason -> DOMException:AbortError(This operation was aborted) message="This operation was aborted"
explicit-reason -> same=true object:[object Object]
static-abort -> aborted=true string:pre-aborted
timeout-reason -> DOMException:TimeoutError(The operation was aborted due to timeout) message="The operation was aborted due to timeout"
any-reason -> same=true Error:Error(first won)
any-preborted -> aborted=true string:already
fire-once -> events=1 reasonKept=number:1
late-listener-after-task -> not-yet onabort=false aborted=true
throwIfAborted -> same:Error:Error(propagated)
non-error-reason -> aborted=true number:42
event-type -> Event
race -> winner=string:second-source
The first line is the one people get wrong. A controller that has never been aborted still has a reason property, and the probe's hasReason=true comes from a plain 'reason' in signal test. The property is part of the interface, not a marker of cancellation, so a helper that decides whether to take a fast path by checking for the property will always take the fast path. The two fields that answer the question are signal.aborted and, if a reason matters, signal.reason !== undefined.
The default reason is a DOMException. The probe prints both parts of its identity: DOMException:AbortError. That split is worth holding onto, because a logging helper that switches on error.constructor.name will group an abort together with every other platform exception, while a helper that switches on error.name separates it. Neither string says anything about the code that cancelled, and the message This operation was aborted is the platform's own wording rather than a description of the work that stopped.
Passing a reason changes what the consumer sees but not the storage rule. The probe aborts with a plain object and then compares the stored value to the original with strict equality, and the comparison is same=true. Nothing is copied, wrapped, cloned or serialized on the way in. That matters for two reasons. First, a consumer can read fields it defined itself, such as a request id or a retry count, straight from signal.reason. Second, mutating the object after the abort is visible to every later reader, because there is exactly one object, and a consumer that tries to freeze the reason afterward is changing a shared value rather than protecting a copy.
The interface also accepts values that are not errors. The probe aborts with the number 42 and the signal reports aborted=true with a number:42 reason. A cancellation handler written for errors will then read reason.name and get undefined, and a handler that formats reason.message.length will throw a TypeError from inside its own error path, which is the least useful place for a second failure. The defensive shape is a small normalizer at the top of the handler: use the reason when it is an Error, and otherwise build one that carries the value in a field.
AbortSignal.abort() short-circuits the whole sequence. The probe calls it with the string pre-aborted and finds a signal that is already aborted at construction, with that string as its reason. This static constructor is the only way to get an aborted signal without an intermediate controller, and it is the natural choice for a function that wants to return a cancelled result to signal "do not continue" without allocating a controller it will never use again.
The abort event itself carries nothing. The probe registers a listener, aborts, and asks the handler what kind of object it received; the printed type is Event. There is no reason field on that object, so the common snippet signal.addEventListener('abort', event => log(event.reason)) prints undefined while the reason sits one property away on the signal. Reading signal.reason inside the handler also works for a reason that arrives after the listener is registered, which is the case that matters when the abort and the handler live in different modules. The reason sits on the signal rather than on the event for the same reason a rejected promise carries its error on the promise, and promise combinator failure modes shows how each of all, allSettled and any reports which member failed.
How timeout and any pick a single reason
The platform ships two composite sources, and each answers a different question. AbortSignal.timeout() answers "after a deadline" and AbortSignal.any() answers "whichever of these happened first". Both produce a signal shaped like any other, and both choose a single reason rather than merging.
AbortSignal.timeout(15) starts with aborted=false, which the probe records before waiting. When the timer fires, the reason is a DOMException whose name is TimeoutError and whose message is The operation was aborted due to timeout. That is a different name from the plain AbortError, and the difference is the only structural way to tell a deadline apart from a deliberate cancel on a signal you did not create yourself. A retry policy that treats AbortError as final and TimeoutError as retryable depends entirely on this name check, so a handler that only compares against AbortError silently treats a timeout as a permanent stop.
The duration is also embedded in the message text, and a helper that parses it back out will find the platform's phrasing rather than a bare number. A page that needs to know which deadline expired should keep its own table keyed by the value it passed, because the platform does not hand back the delay as a field.
AbortSignal.any() takes an iterable of signals and returns one signal that aborts when the first member aborts. The probe creates two controllers, composes them, aborts the first with a named error, then aborts the second, and compares the composite's reason to the first error with strict equality; the result is same=true. Composition forwards one reason by identity and ignores every later member, so a consumer that wants to know which source fired has to attach that information to the reason before passing it in, typically by wrapping each member's reason in an object with a source label. Once the composite has aborted, later aborts on other members are not observable through it at all.
The probe also shows that composition is eager rather than lazy. Building a composite from one signal that was already aborted and one that is still live produces a signal that is aborted during the any() call, with the reason already taken from the dead member. There is no window in which the returned signal is live. A function that composes a caller's signal with a fresh timeout therefore has to handle the case where the caller's signal was already dead, and it cannot assume the timeout branch will ever be reached.
The race case fixes the remaining ambiguity. The probe aborts the second controller first and the first controller second, then reads the composite's reason, and the printed winner is the second source. The order that decides is the order of the abort() calls, not the order of the array, which means a page that lists a deadline first and a user cancel second still reports the user cancel when the user acts first. That is the behaviour most callers want, and it is worth stating because an implementation that walked the array and preferred the earliest member would look identical in every single-source test.
Combining the two sources is where the names pay off. AbortSignal.any([AbortSignal.timeout(5000), userSignal]) produces a signal that reports TimeoutError when the deadline passes and the caller's own reason when the caller cancels. One catch block that reads reason.name can then separate a deadline from a cancel without tracking a flag, and a cancel that passes an Error with a code keeps that code, because composition preserved the object rather than replacing it with a platform exception.
How many times the abort event fires
The controller is a one-shot device, and the probe pins down what that means for both the event and the stored reason. It calls abort() twice on the same controller with two different values and counts the abort events; the printed count is events=1, and the retained reason is the first value. So abort() is idempotent with respect to the event, and the first reason is final. A page that calls abort() from both a timeout handler and a cleanup path does not need its own guard to avoid a double delivery, though a guard is still worth having when the second call would do real work before reaching abort().
The late-listener case is the sharpest edge in the whole interface. The probe aborts first, then registers a listener, then also assigns signal.onabort, then waits a full macrotask. The printed line is late-listener-after-task -> not-yet onabort=false aborted=true. Neither form ran. Waiting longer would not help, because the event was already dispatched during the earlier abort() call and there is no replay for a later subscriber. A helper that hides the signal behind an interface and attaches its listener inside the function that consumes it therefore has a silent hole: if the abort happened before the helper was called, the helper waits forever.
The correct shape tests the flag first. A consumer that wants to run once on either branch reads signal.aborted, handles the dead case immediately, and only then attaches a listener for the live case, with { once: true } so the handler is removed after the single dispatch. The probe's own wait for the timeout reason uses exactly that pattern, and it is the reason the timeout case has to keep a separate timer alive: the abort arrives from a source the probe does not control, so the listener is the only way to observe it.
There is a second, subtler consequence. Because a listener registered before the abort runs during the abort() call, the code that follows abort() on the same stack already sees the handler's effect. That makes abort() a synchronous broadcast for existing subscribers and a no-op for everyone else, which is a combination that behaves differently from an event emitter that buffers. A test that aborts and then immediately asserts that a spy was called sees it, and the same test written against an asynchronous emitter would need to await. That synchronous delivery is the same turn boundary described in microtasks and timers, where a callback queued as a microtask runs before the next task but a listener called during dispatch runs before either.
Why throwIfAborted rethrows the same object
The signal offers a guard that turns an aborted state into a thrown value, and the probe checks what exactly comes out. It aborts with a named error, calls throwIfAborted(), and compares the caught value to the original with strict equality. The printed result is same:Error:Error(propagated), so the guard rethrows the stored reason rather than a fresh wrapper. The caller's catch block therefore receives the canceller's own object, with whatever fields were attached to it, which is the cheapest way to carry a request id or a reason code across module boundaries.
The guard is a no-op while the signal is live, so it can sit at the top of every step that is about to do work. That placement is what makes cancellation prompt: a loop that calls the guard before each iteration stops at the first iteration that runs after the abort, rather than at the next awaited boundary. Inside an async function the synchronous throw becomes a rejected promise automatically, which is usually the shape a caller expects, and in a plain function it propagates as a normal exception.
What a check must not assume is that the thrown value is shaped like an error. The probe's last case aborts with the number 42 and confirms that aborted is true and the reason is a number. A catch block that logs error.name will log undefined, and a block that calls error.message.trim() will throw while handling a cancel. The rule that survives every observed case is: test signal.aborted, treat reason as an unknown value, and only narrow to Error after checking. That order also keeps a composite signal honest, because a time-out reason is a DOMException and a cancel reason may be anything at all.
Record an abort reading
An abort reading supports a narrow claim: this runtime, this source, this reason, produced these observations. Four fields keep it checkable:
- The source type, because a plain controller, a static abort, a timeout, and a composite each choose their reason by a different rule.
- The reason's constructor and name, because
AbortErrorandTimeoutErrorshare a constructor and differ only in the name. - The identity result, because a consumer that compares the caught value to the canceller's object is relying on forwarding rather than copying.
- Whether the listener was attached before or after the abort, because a late listener is skipped no matter how long it waits.
Public sources
- MDN: AbortController
- MDN: AbortSignal
- MDN: AbortSignal.any()
- MDN: AbortSignal.timeout()
- WHATWG DOM: aborting a signal
- BotBrowser: automation consistency reference
Practical conclusion
Treat the reason as the only value a cancel carries and treat the signal as a one-shot flag: read signal.aborted before attaching a listener, name the source when a record describes a cancellation, and compare against reason.name rather than the reason's constructor when a deadline and a manual cancel have to be separated. For the task-scheduling side of the same mechanism, scheduler priorities and post-task work shows how a cancellation token changes the order in which queued work retires, and for the resource side, port protection uses a bounded timeout signal to keep a local probe from hanging.
This article is part of the guide: What Is a Browser Fingerprint?
Related Articles
Proxy Traps and the Invariants the Runtime Enforces
FingerprintMobile Viewport Resize Events: Order, Debounce, and Layout
FingerprintPromise Combinators: What Fails First
FingerprintMicrotasks and Timers: The Order Your Code Actually Runs
FingerprintWeakMap and WeakSet: What They Hold and What They Drop
FingerprintSparse Array Holes and What Each Method Does With Them
Take BotBrowser from research to production
The guides cover the model first, then move into cross-platform validation, isolated contexts, and scale-ready browser deployment.