Cancelling Worker Tasks with AbortSignal
Cancellation across a thread boundary has three separate mechanisms because a job can be in three states, and only the first is easy: still queued, already running, or running with no yield points at all.
This completes the dispatch story in Worker Pool Management, part of Web Workers Architecture & Communication. Get the queued case right and most cancellation is free; the other two need the worker’s cooperation, designed in advance.
Three States, Three Mechanisms
| Job state | Mechanism | Cost of cancelling | CPU saved |
|---|---|---|---|
| Queued, not dispatched | remove from the queue | nothing | all of it |
| Running, task yields | CANCEL message, checked between slices |
one message | most of it |
| Running, no yield points | terminate() and rebuild |
5–15 ms + lost warm state | all of it |
The first row is where the value is. A search-as-you-type box that submits per keystroke will have several superseded jobs sitting in the queue at any moment; dropping them before dispatch costs nothing and saves the entire computation. Teams often reach for terminate() first and never implement the queue case, which is backwards — the cheap mechanism covers the common case.
Plumbing the Signal
AbortSignal cannot be cloned, so it stays on the page and drives a message:
// pool.ts — cancellation across all three states
interface Job { id: number; message: unknown; reject: (e: Error) => void; worker?: Worker }
export class Pool {
#queued: Job[] = [];
#running = new Map<number, Job>();
run<T>(message: unknown, { signal }: { signal?: AbortSignal } = {}): Promise<T> {
const id = ++this.#seq;
return new Promise<T>((resolve, reject) => {
const job: Job = { id, message, reject };
signal?.addEventListener('abort', () => {
// State 1: still queued — drop it and never spend a cycle.
const index = this.#queued.findIndex((j) => j.id === id);
if (index >= 0) {
this.#queued.splice(index, 1);
reject(signal.reason as Error);
return;
}
// State 2: already running — ask the worker to stop.
const active = this.#running.get(id);
active?.worker?.postMessage({ kind: 'CANCEL', id });
// The promise rejects when the worker confirms, so the slot is released properly.
}, { once: true });
this.#queued.push(job);
this.#pump();
});
}
}
// worker.ts — the cooperative half
const cancelled = new Set<number>();
self.addEventListener('message', (event: MessageEvent) => {
if (event.data.kind === 'CANCEL') { cancelled.add(event.data.id); return; }
void run(event.data);
});
async function run({ id, rows }: { id: number; rows: Float64Array }): Promise<void> {
let sum = 0;
for (let i = 0; i < rows.length; i++) {
sum += rows[i];
if ((i & 0xffff) === 0) {
await drainInbox(); // a macrotask: CANCEL can arrive here
if (cancelled.delete(id)) {
self.postMessage({ id, kind: 'ABORTED' }); // confirm, so the slot is freed
return;
}
}
}
self.postMessage({ id, kind: 'DONE', sum });
}
Two details are load-bearing. The worker confirms the abort rather than going silent, so the pool can free the slot and settle the promise deterministically. And the check happens after a macrotask yield — a microtask does not let the message queue drain, so the CANCEL would not have been delivered yet.
terminate() has skipped the case that occurs most often.Cancelling Without Draining the Inbox
Some tasks genuinely cannot yield often: a WebAssembly call that runs for 300 ms, a tight numeric kernel where a macrotask yield every few milliseconds costs real throughput. A shared flag solves it, because shared memory is readable mid-loop without touching the event loop at all:
// page.ts — one Int32 slot per job, in a small shared control buffer
const control = new Int32Array(new SharedArrayBuffer(64 * 4));
signal.addEventListener('abort', () => Atomics.store(control, slot, 1));
worker.postMessage({ kind: 'RUN', control, slot, rows }, [rows.buffer]);
// worker.ts — a load every 65,536 iterations costs a few nanoseconds
for (let i = 0; i < rows.length; i++) {
sum += rows[i];
if ((i & 0xffff) === 0 && Atomics.load(control, slot) === 1) {
self.postMessage({ id, kind: 'ABORTED' });
return;
}
}
The read is roughly 10–30 ns on a cold cache line and effectively free on a warm one, so the check can be far more frequent than a message-based one — sub-millisecond cancellation latency without giving up the event loop.
The SharedArrayBuffer above only exists on a document serving Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp, with every cross-origin subresource CORP- or CORS-eligible. Feature-detect the constructor and fall back to the message-based flag, so cancellation degrades in latency rather than disappearing. The full requirements are in SharedArrayBuffer & Atomics.
Composing With the Rest of the Application
AbortSignal is the right interface precisely because everything else already speaks it. A job’s signal usually combines several independent reasons to stop:
const signal = AbortSignal.any([
componentUnmounted.signal, // the view went away
supersededByNewInput.signal, // a newer request replaced this one
AbortSignal.timeout(30_000), // a deadline
]);
const rows = await pool.run<Float64Array>({ kind: 'FILTER', query }, { signal });
Two habits keep this pleasant. Distinguish the reasons when handling the rejection — a TimeoutError deserves different telemetry from an ordinary AbortError, and treating them alike hides genuinely slow jobs. And always reject with the signal’s reason rather than a generic error, so the caller can tell why without inspecting state that has since changed.
Choosing the Check Interval
Cancellation latency is set by how often the worker looks, and both extremes are wrong: checking too rarely means a cancelled job keeps a pool slot for hundreds of milliseconds, while checking too often costs throughput on a hot loop.
A workable rule is to make the interval a time rather than an iteration count, since per-iteration cost varies by orders of magnitude between workloads:
let nextCheck = performance.now() + 8; // check about every 8 ms
for (let i = 0; i < rows.length; i++) {
process(rows[i]);
if (performance.now() >= nextCheck) {
if (Atomics.load(control, slot) === 1) return abort();
nextCheck = performance.now() + 8;
}
}
performance.now() itself costs roughly 20–50 ns, so calling it once per iteration is measurable on a tight loop — hence the hybrid in practice: a cheap bitmask test to decide whether to call the clock, then the time comparison. Eight milliseconds is a good default because it is well under a frame, so a user who clicks “stop” sees it take effect within the same interaction, while the check itself is far too infrequent to matter for throughput.
For message-based cancellation the same interval applies, with one extra constraint: the yield must be a macrotask, so the check interval is also the rate at which the worker’s inbox drains. That makes 8 ms a reasonable ceiling for everything the worker needs to notice, not just cancellation.
Gotchas
Rejecting the promise without telling the worker. The caller stops waiting, the worker keeps computing, and the pool slot stays occupied. Cancellation has to reach both the promise and the thread.
Cancelling a parked task. A worker awaiting credit or a lock is not running its loop, so a flag it only checks in the loop is never read. The park must also be interruptible — the same requirement described in Implementing Backpressure with Message Credits.
A cancel racing the completion. The worker may finish just as the cancel arrives, so the pool must tolerate a DONE for a job it believes was cancelled — discard it rather than treating it as a protocol error, and never resolve a promise that was already rejected.
Treating an abort as an error in telemetry. Cancellations are normal and frequent — a user typing produces one per keystroke. Reporting them as failures buries real errors under noise and makes the error rate meaningless. Classify by error.name at the boundary: AbortError is expected, TimeoutError is a signal worth watching, anything else is a bug.
Assuming the pool slot is freed on abort. The slot is only free when the worker confirms, or when the supervisor terminates it. A pool that decrements its busy count on the abort itself will dispatch a second job to a worker that is still running the first — the two jobs then interleave on one thread and both run slowly.
Leaking abort listeners. addEventListener('abort', …) on a long-lived signal accumulates one listener per job. Use { once: true } and remove it when the job settles, or a page-lifetime signal collects thousands.
No cancellation path at all on a long job. If a task can run for more than about a second, it needs one — otherwise a user who navigates away leaves a worker occupied, and the next screen’s work queues behind something nobody wants.
terminate() while other jobs are in flight. In a pool, terminating a member to cancel one job also kills every other job on that worker. Track what a worker is running before killing it, and reject those jobs explicitly.
Performance Note
In a search-as-you-type field on a 2023 laptop, a user typing eight characters at 120 ms intervals submitted eight 180 ms filter jobs. Without cancellation the pool ran all eight — 1.44 s of CPU for a result only the last one contributed to. Dropping superseded jobs before dispatch reduced that to one dispatch plus seven queue removals: 180 ms of CPU, a 87% saving, and the final result arrived 340 ms sooner because it was not queued behind stale work.