BRAINSTORM_DESIGN_SPACE.md
February 12, 2026 · View on GitHub
What the Protocol Must Express
From the principles, the protocol needs to surface exactly four things:
- State: has abort occurred? — A synchronous boolean read.
- Reason: why? — A synchronous read of an arbitrary value.
- Subscribe: notify me when abort occurs. — Register a callback.
- Unsubscribe: stop notifying me. — Remove a previously registered callback.
Capabilities (1) and (2) are straightforward properties. The interesting design question is (3) and (4): the subscription mechanism.
The Subscription Problem
ECMAScript has no general-purpose event subscription primitive. The patterns that exist in the ecosystem are:
EventTarget (Web API — not available)
signal.addEventListener('abort', fn);
signal.removeEventListener('abort', fn);
Multi-consumer, but requires function reference identity for removal, and depends on a Web API.
.on / .off (Node.js EventEmitter — not standardized)
signal.on('abort', fn);
signal.off('abort', fn);
Same shape as EventTarget but with different method names. Also not ECMAScript.
Single handler property (DOM on* pattern)
signal.onabort = fn;
signal.onabort = null;
Single-consumer only. Violates Principle 9 (multi-consumer).
Subscribe-returns-unsubscribe (Observable / reactive pattern)
const unsubscribe = signal.subscribe(fn);
// later:
unsubscribe();
Multi-consumer. Each subscription returns its own independent teardown function. No need for function reference identity — the returned handle is the unsubscription token. This pattern exists in ECMAScript today (no Web API dependency) and is used by numerous libraries (RxJS, MobX, Redux, Solid, Svelte, etc.).
Current WHATWG Proposal
There is a current WHATWG proposal (https://github.com/whatwg/dom/pull/1425)
that has been recently opened that suggests adding an addAbortCallback(...)
API to AbortSignal that would exist in addition to addEventListener('abort', ...)
Subscribe-Returns-Unsubscribe
The subscribe-returns-unsubscribe pattern has significant advantages for this protocol:
- Independent unsubscription. Each call to subscribe returns a unique handle. Consumers don't need to hold a reference to their original callback to unsubscribe. This is especially important when the callback is an anonymous arrow function, which is the overwhelmingly common case.
- No reference identity requirement.
removeEventListenerrequires passing the exact same function reference. This fails silently when the consumer wraps or rebinds the callback — a common source of bugs inEventTarget-based code. - Naturally multi-consumer. Each subscription is independent. No single-handler slot to fight over.
- Composable with
using. The returned unsubscribe function can be made disposable (Symbol.dispose), allowingusing unsub = signal.subscribe(fn)to automatically clean up when the scope exits. - No dependency on Web APIs. The entire mechanism is plain ECMAScript — a method call returning a function.
Observables as a Subscription Mechanism?
The dormant TC39 Observable proposal (Stage 1, now largely replaced by the WICG effort) would provide a standardized subscription model with built-in teardown semantics. In principle, a signal could expose an Observable that emits once (the abort reason) and then completes, with subscription teardown serving as the unsubscribe mechanism:
const subscription = signal.aborted$.subscribe({
next(reason) { /* abort occurred */ }
});
// later:
subscription.unsubscribe();
However, Observable is likely too heavyweight for this use case:
- Observables model multi-value async streams. An abort signal is a single, one-time event. The Observable machinery — next/error/complete callbacks, completion semantics, error channels — is unnecessary overhead for a boolean state transition.
- Conceptual mismatch. Observables are pull-to-subscribe, push-to-deliver, with rich lifecycle. An abort signal is a simple notification with no sequence, no completion distinct from the event itself, and no error channel (the reason is the value, not an error in the Observable sense).
- Dependency ordering. If the abort protocol depends on Observable, it cannot ship until Observable does. Observable has been at Stage 1 for a considerable time. The abort protocol should be self-contained.
More fundamentally, Observable itself is likely to be a consumer of the abort protocol. An Observable subscription may need to accept an abort signal to allow external cancellation of the stream. If the abort protocol were defined in terms of Observable, this would create a circular dependency — Observable needs the abort protocol, but the abort protocol needs Observable. The abort protocol must be a lower-level primitive that Observable (and other higher-level abstractions) can build upon, not the other way around.
The subscribe-returns-unsubscribe pattern described above is compatible with Observable — an Observable adapter could be layered on top without the protocol itself depending on it.
The other obvious key issue is that the Observable proposal in TC-39 is effectively
dead and has been replaced by the WICG proposal, which cannot be normatively
referenced in TC-39 (it's a Web platform API so has same dependency constraints
as AbortSignal).
Protocol Identification
How does a consumer know an object satisfies the abort protocol? Three options:
Option A: Well-known Symbol (like Symbol.iterator)
A new well-known symbol (e.g., Symbol.abort) that, when called, returns a protocol
object. This is the most TC39-idiomatic approach — it's exactly how iterables,
disposables, and other protocols are defined.
const signal = obj[Symbol.abort]();
signal.aborted // boolean
signal.reason // any
signal.subscribe(fn) // → unsubscribe function
Option B: Duck typing (like thenables)
Any object with the right shape is treated as a signal. No symbol needed.
// Anything with .aborted, .reason, and .subscribe is a signal
Simpler adoption but weaker identification. Risks false positives from objects that coincidentally have these property names.
Option C: Concrete class
A new built-in CancelSignal class (or similar). The most complete approach but
the largest surface area, and harder to retrofit onto AbortSignal.
Open Design Questions
-
Protocol identification mechanism. Symbol-based, duck typing, or concrete class? A Symbol is the most consistent with TC39 precedent for protocols.
-
Shape of the protocol object. Is the subscribe method directly on the signal, or does a Symbol method return a separate subscription-capability object (paralleling how
Symbol.iteratorreturns a separate iterator object)? -
Should the unsubscribe return be disposable? If the unsubscribe function has a
Symbol.disposemethod (or is theSymbol.disposemethod), it integrates withusingfor automatic cleanup. This is attractive but adds a dependency on the Explicit Resource Management proposal.
Design Examples
The following examples illustrate how each protocol option could look in practice
across the key use cases: basic consumption, the AbortSignal compatibility bridge,
the never-aborts sentinel, and signal composition.
Note: These are NOT intended to be concrete proposals. These are meant not to propose a solution but to give a starting point to discuss and contrast the design principles. Take each with a grain of salt. I tend to think best in terms of concrete examples to help ground my thoughts, so this is all just pure brainstorming.
Option A: Well-Known Symbol Protocol
A new Symbol.abort is introduced. An object is "cancelable" if it has a
[Symbol.abort]() method that returns a protocol object with aborted,
reason, and subscribe.
Basic Consumer
async function doWork(signal) {
// Obtain the protocol object
const cancel = signal[Symbol.abort]();
// Pre-check (Principle 3)
if (cancel.aborted) throw cancel.reason;
// Subscribe (Principle 1)
const unsubscribe = cancel.subscribe((reason) => {
// Synchronous notification (Principle 2)
cleanup();
});
try {
const result = await someAsyncWork();
// Post-check (Principle 4)
if (cancel.aborted) throw cancel.reason;
return result;
} finally {
// Unsubscribe (Principle 8)
unsubscribe();
}
}
AbortSignal Compatibility
AbortSignal already has .aborted and .reason. However, because the protocol
object returned by [Symbol.abort]() is a separate object, these must be
proxied through as getters. The subscription must also be bridged from
EventTarget to the subscribe-returns-unsubscribe pattern.
WHATWG would add one method to AbortSignal.prototype:
AbortSignal.prototype[Symbol.abort] = function () {
const signal = this;
return {
get aborted() { return signal.aborted; },
get reason() { return signal.reason; },
subscribe(fn) {
signal.addEventListener('abort', fn, { once: true });
return () => signal.removeEventListener('abort', fn);
}
};
};
Note that each call to [Symbol.abort]() allocates a new wrapper object.
This is consistent with how Symbol.iterator works (each call returns a fresh
iterator), but for a protocol object that is typically obtained once and held for
the duration of the work, the allocation cost is minimal.
Never-Aborts Sentinel
const neverAborts = {
[Symbol.abort]() {
return {
aborted: false,
reason: undefined,
subscribe(_fn) { return () => {}; } // discard, no-op unsubscribe
};
}
};
// Consumer code works uniformly:
await doWork(neverAborts);
Composition (any)
function any(signals) {
// Obtain protocol objects for all inputs
const cancels = signals.map(s => s[Symbol.abort]());
// Check if any input is already aborted (Principle 3)
for (const cancel of cancels) {
if (cancel.aborted) {
return alreadyAborted(cancel.reason);
}
}
// Build a composite signal
let subscribers = [];
let aborted = false;
let reason;
const unsubscribes = [];
const composite = {
get aborted() { return aborted; },
get reason() { return reason; },
subscribe(fn) {
if (aborted) return () => {}; // already fired, ignore (Principle 13)
subscribers.push(fn);
return () => {
subscribers = subscribers.filter(f => f !== fn);
};
},
[Symbol.abort]() { return this; }
};
// Subscribe to all inputs
for (const cancel of cancels) {
const unsub = cancel.subscribe((r) => {
if (aborted) return; // another input already fired
aborted = true;
reason = r;
// Unsubscribe from all inputs (Principle 8)
for (const u of unsubscribes) u();
// Notify composite subscribers synchronously (Principle 2)
for (const fn of subscribers) fn(r);
subscribers = [];
});
unsubscribes.push(unsub);
}
return composite;
}
Observations on Option A
- Two-step access. Consumers call
[Symbol.abort]()before interacting with the signal. This parallels[Symbol.iterator](). Unlike iterators, where the distinction between "iterable" and "iterator" is meaningful (an iterable can produce multiple independent iterators), a cancel signal has singular state — there is no analogous reason to separate the source from the protocol object. - Protocol object identity. The source object can return itself (as arrays do
for
Symbol.iterator). If so, the protocol surface is directly on the signal object, and the Symbol method serves as identification. This collapses the distinction between Options A and D. - Symbol-based identification. A well-known Symbol cannot collide with existing string-named properties. The Symbol method produces the protocol object, so calling it inherently yields an object with the expected shape.
Option B: Duck Typing
No Symbol. Any object with aborted (boolean), reason (any), and subscribe
(function returning unsubscribe function) is treated as a signal.
Basic Consumer
async function doWork(signal) {
// Pre-check
if (signal.aborted) throw signal.reason;
// Subscribe
const unsubscribe = signal.subscribe((reason) => {
cleanup();
});
try {
const result = await someAsyncWork();
// Post-check
if (signal.aborted) throw signal.reason;
return result;
} finally {
unsubscribe();
}
}
AbortSignal Compatibility
AbortSignal already has .aborted and .reason, which satisfy two of the three
protocol requirements out of the box. Only the subscription mechanism needs bridging.
WHATWG would add one method to AbortSignal.prototype:
AbortSignal.prototype.subscribe = function (fn) {
this.addEventListener('abort', fn, { once: true });
return () => this.removeEventListener('abort', fn);
};
This is the lightest retrofit of any option — one method addition, no wrapper objects,
no Symbols. AbortSignal satisfies the protocol directly.
Never-Aborts Sentinel
const neverAborts = Object.freeze({
aborted: false,
reason: undefined,
subscribe(_fn) { return () => {}; }
});
Observations on Option B
- Direct access. No
[Symbol.abort]()call. Consumers interact directly with the signal's properties and methods. AbortSignalretrofit. One method addition (.subscribe). The existing.abortedand.reasonproperties already conform. No wrapper objects.- No formal identification. An object with an unrelated
.abortedproperty and a.subscribemethod would satisfy the protocol shape. The collision risk depends on the specificity of the property names chosen. - Name collision. The name
subscribeis used by other patterns (Observable, pub/sub libraries). A more specific name would reduce collisions but may conflict with existing properties (e.g.,onabortonAbortSignalis single-handler).
Option C: Concrete Built-in Class
A new CancelSignal class is added to ECMAScript, with a corresponding
CancelController for the trigger side.
Basic Consumer
async function doWork(signal) {
// Pre-check
if (signal.aborted) throw signal.reason;
// Subscribe
const unsubscribe = signal.subscribe((reason) => {
cleanup();
});
try {
const result = await someAsyncWork();
// Post-check
if (signal.aborted) throw signal.reason;
return result;
} finally {
unsubscribe();
}
}
Controller Side
const controller = new CancelController();
const signal = controller.signal;
// Pass signal to consumers
doWork(signal);
// Later, trigger abort
controller.cancel(new Error('timeout'));
AbortSignal Compatibility
This is the hardest option to retrofit. AbortSignal is an existing class with its
own prototype chain, and it cannot retroactively extend or mix in a new built-in
class. There are several possible paths, none fully satisfying:
Path 1: Spec-level coordination. WHATWG redefines AbortSignal to extend
CancelSignal (or implement a shared interface). This requires cross-spec
coordination between TC39 and WHATWG, and would be a breaking change if
AbortSignal's prototype chain is altered.
// AbortSignal would inherit .subscribe from CancelSignal.prototype
// But AbortSignal already has its own .aborted and .reason — potential conflicts
Path 2: Protocol Symbol. AbortSignal implements the protocol via a well-known
Symbol, essentially falling back to Option A or D for interop:
AbortSignal.prototype[Symbol.abort] = function () {
return this;
};
AbortSignal.prototype.subscribe = function (fn) {
this.addEventListener('abort', fn, { once: true });
return () => this.removeEventListener('abort', fn);
};
But if a Symbol is needed anyway, the concrete class adds little over Option D.
Path 3: Bridge utility. A static method wraps an AbortSignal:
const cancelSignal = CancelSignal.from(abortSignal);
This works but means every API that accepts a CancelSignal must also accept an
AbortSignal and wrap it, or callers must remember to wrap. This is a persistent
source of friction.
Never-Aborts Sentinel
// Could be a static property
const signal = CancelSignal.none;
// Or a static factory
const signal = CancelSignal.never();
Observations on Option C
- API surface. A concrete class can provide static methods (
CancelSignal.any(),CancelSignal.never(),CancelSignal.from()) andinstanceofchecking. - Specification footprint. Defines both a signal class and a controller class, with constructors, prototypes, and internal slots.
AbortSignalretrofit.AbortSignalcannot retroactively extendCancelSignal. The relationship requires duck typing, a shared protocol Symbol, or spec-level coordination between TC39 and WHATWG.- Coexistence with
AbortSignal. In environments that have both, there are two signal types serving the same purpose. APIs must decide which to accept, or accept both and bridge between them. instanceofand realms.instanceofprovides identification within a single realm but does not work across realm boundaries.
Option D: Symbol Protocol With Direct Conformance
A hybrid of Options A and B. A Symbol.abort is used for identification, but
the protocol properties (aborted, reason, subscribe) live directly on the
object — the Symbol method simply returns this.
Basic Consumer
async function doWork(signal) {
// Pre-check
if (signal.aborted) throw signal.reason;
// Subscribe
const unsubscribe = signal.subscribe((reason) => {
cleanup();
});
try {
const result = await someAsyncWork();
if (signal.aborted) throw signal.reason;
return result;
} finally {
unsubscribe();
}
}
In practice, consumers never call [Symbol.abort]() — they use the properties
directly. The Symbol serves only as a type brand for identification:
// A library that accepts an optional signal:
function startWork(options) {
const signal = options?.signal;
if (signal && !signal[Symbol.abort]) {
throw new TypeError('signal does not satisfy the cancelation protocol');
}
// ... use signal.aborted, signal.subscribe, etc. directly
}
AbortSignal Compatibility
AbortSignal already has .aborted and .reason. WHATWG would add two things:
the Symbol brand and the .subscribe method:
AbortSignal.prototype[Symbol.abort] = function () { return this; };
AbortSignal.prototype.subscribe = function (fn) {
this.addEventListener('abort', fn, { once: true });
return () => this.removeEventListener('abort', fn);
};
This is nearly as light as Option B (one extra line for the Symbol brand), with the
added benefit of reliable identification. Since [Symbol.abort]() returns
this, there is no wrapper allocation — AbortSignal instances are the protocol
objects.
Never-Aborts Sentinel
const neverAborts = Object.freeze({
aborted: false,
reason: undefined,
subscribe(_fn) { return () => {}; },
[Symbol.abort]() { return this; }
});
Observations on Option D
- Direct access with Symbol identification. Consumers interact with the signal's properties directly. The Symbol is present for identification but not required for access.
- The Symbol is a brand, not a factory. Unlike
Symbol.iterator(which returns a new iterator each time),[Symbol.abort]()always returnsthis. A cancel signal has singular state — there is no analogous reason to produce independent protocol objects from the same source. AbortSignalretrofit. Adds two things: the Symbol and.subscribe. Existing.abortedand.reasonproperties already conform. No wrapper objects.- Disconnected identification. The Symbol asserts protocol conformance, but the
consumer accesses
.aborted,.reason, and.subscribeas named properties on the same object. The Symbol's presence does not guarantee the existence or correctness of those properties. A buggy implementation could have[Symbol.abort]but missing or incorrect properties. Consumer code that wants to be robust must still verify the properties exist, regardless of whether the Symbol is present.