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?
answer
- the event may have already fired
- reject with the reason you were given
- signalling is not stopping
- listeners outlive short operations
- once fires only if it fires
basics
~20 sCheck 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.
solid answer
~50 sFour obligations. First, **pre-check**: call `signal?.throwIfAborted()` before doing anything, because the signal may already be aborted when you are called and the `abort` event will never fire again. Second, **subscribe**: `signal.addEventListener('abort', onAbort, { once: true })` and have `onAbort` reject with `signal.reason` — not with an error you invent, so callers' `AbortError` checks still work. Third, **actually stop the work**: clear the timer, close the handle, tear down the resource. Rejecting the promise while the work continues is abandonment, not cancellation. Fourth, **clean up**: remove the abort listener in the settle path. A long-lived signal — one per component or per request scope — outlives individual operations, so every un-removed listener stays reachable along with the closure it captured and everything that closure holds. Thousands of short operations against one signal is exactly the shape that produces a slow leak.
code
javascript · 18 linesfunction delay(ms, { signal } = {}) {
return new Promise((resolve, reject) => {
// 1. already aborted? throwing here rejects the promise with signal.reason
signal?.throwIfAborted();
const onAbort = () => {
clearTimeout(id); // 3. actually stop the work
reject(signal.reason); // 2. reject with the given reason, verbatim
};
const id = setTimeout(() => {
signal?.removeEventListener('abort', onAbort); // 4. clean up on success
resolve();
}, ms);
signal?.addEventListener('abort', onAbort, { once: true });
});
}go deeper
Know the shape of an abortable function: it takes { signal }, checks it, and rejects when aborted. Recall that the rejection value should be signal.reason.
Explain why the pre-check exists — the abort event fires only once and may already have fired — and why { once: true } alone does not clean up the successful path.
Show the leak concretely: a scoped signal outliving hundreds of operations, each leaving a listener closure reachable. Then show the settle-path removal, the idempotent abort handler, and the difference between rejecting and genuinely stopping the work.
Set the house rule that every async boundary threads its signal downwards, so cancellation reaches the real resource rather than dying one layer in, and define how partial effects from a cancelled operation are reconciled.
## The contract An abortable function conventionally takes the signal in an options bag — `doWork(input, { signal })` — treats it as optional, and guarantees that after an abort it settles promptly with `signal.reason`. Meeting that guarantee takes four steps, and each has a distinct failure mode. ## 1. Pre-check, because the event may already be gone The `abort` event fires exactly once, at the moment `abort()` is called. If the signal was aborted before your function ran, subscribing gets you nothing — you would wait forever for an event that already happened. ```js signal?.throwIfAborted(); // throws signal.reason if already aborted ``` Inside a promise executor this is especially neat: a synchronous throw in the executor rejects the returned promise, so the pre-check and the abort path produce the same rejection value with no special-casing. Outside an executor, in an async function, `throwIfAborted()` rejects the returned promise for the same reason. The failure mode of skipping it: a caller that cancels early still pays for the whole operation. In a fast-typing search box where every keystroke aborts the previous request, the aborted-before-start case is not an edge case — it is most of the traffic. ## 2. Subscribe, and reject with the reason ```js const onAbort = () => { stopTheWork(); reject(signal.reason); }; signal.addEventListener('abort', onAbort, { once: true }); ``` Reject with `signal.reason` verbatim. Inventing `new Error('aborted')` destroys the caller's ability to classify the rejection: their `err.name === 'AbortError'` check now fails, so your cancellation is reported and retried as a genuine failure. ## 3. Stop the work, not just the promise Rejecting the promise only ends the caller's wait. If the timer keeps ticking, the socket stays open, or the worker keeps computing, you have abandoned the result while still paying for it — and the work may later touch state the caller assumes is finished. The abort handler is where you `clearTimeout`, close the handle, or tear down whatever the operation acquired. When your function is itself a composition of other abortable calls, the right move is to pass the same signal down so cancellation propagates instead of stopping at your layer. ## 4. Clean up the listener This is the one that leaks. Signals are frequently *scoped*, not per-operation: one controller per component, per page view, per request. That signal can live for minutes while hundreds or thousands of short operations run against it. Every `addEventListener('abort', ...)` you never remove stays on the signal's listener list, and the listener closure keeps alive everything it captured — the `reject` function, the operation's buffers, its result object. ```js const id = setTimeout(() => { signal?.removeEventListener('abort', onAbort); resolve(); }, ms); ``` `{ once: true }` only removes the listener when the event *fires*. On the happy path it never fires, so you must remove it explicitly. Wrapping the whole body and removing in a `finally` is the tidier form when you are in an async function. The symptom in production is a heap that grows in proportion to operation count rather than to concurrency, with retained closures rooted at a long-lived signal. ## Settle-once discipline Both paths race: the work may finish microseconds before the abort arrives. Promises absorb this — the second `resolve`/`reject` is ignored — so the promise itself is safe. Your *side effects* are not. `clearTimeout` on a timer that already fired is harmless, but closing a handle you already returned to a pool, or decrementing a counter twice, is not. Make the abort handler idempotent, or guard it with a `settled` flag. ## Do not swallow A tempting shortcut is to `resolve(undefined)` on abort so callers do not need a catch. This is worse than it looks: the caller cannot distinguish "cancelled" from "legitimately produced no result", and downstream code proceeds on a value that was never computed. Reject. ## Optional by default Treat `signal` as optional — `signal?.throwIfAborted()`, `signal?.addEventListener(...)` — so the function stays usable without one. Uniform optional handling also means a caller can wire cancellation later without your signature changing.
- Why is `{ once: true }` on the abort listener not enough to avoid a leak?`once` removes the listener only when the event actually fires. On the successful path abort never happens, so the listener stays attached to a signal that may outlive the operation by minutes, holding its closure and captured state. You still need an explicit `removeEventListener` in the settle path — typically a `finally`.
- Why check signal.aborted before subscribing rather than only listening?Because the `abort` event fires exactly once, at abort time. A signal aborted before your function ran will never fire it again, so a listen-only implementation runs the full operation and ignores a cancellation the caller already requested. `throwIfAborted()` covers the case and rejects with the same reason the listener would use.
- Your function internally calls two other abortable functions. What should it do with the signal?Pass the same signal straight through to both. Cancellation then propagates to the real work instead of stopping at your layer, and each callee rejects with the same reason so the error you surface is consistent. Creating a fresh controller per layer is only warranted when you need to add a condition, and then you compose rather than replace.
- The work finishes at almost the same instant the abort arrives. What has to be true for that race to be safe?The promise itself is safe — a second settle is ignored. The danger is duplicated side effects: releasing a pooled handle twice, double-decrementing a counter, or committing a result after cancellation. Make the abort handler idempotent or guard both paths with a settled flag, and re-check `signal.aborted` before publishing any result.
saying these in an interview costs you the question
- Only subscribes to the abort event and never checks aborted first
- Rejects with a freshly invented Error instead of signal.reason
- Resolves with undefined on abort so callers need no catch
- Leaves the abort listener attached after the operation settles
- Assumes rejecting the promise stops the underlying work