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.

Where a cancellation lands depending on the job's state A job's path through three states with the cancellation outcome for each. While queued, an abort removes the job from the queue immediately, costs nothing, and saves the entire computation. Once dispatched and running, an abort posts a cancel message that the worker observes at its next yield point, so the saving depends on how often the task yields — typically most of the remaining work. If the task has no yield points, the message is never observed and only terminate stops it, discarding the worker's warm state and costing five to fifteen milliseconds to replace. The same abort, three different outcomes queued removed from the queue running, yields stops at the next yield running, no yields only terminate() works saves 100% no CPU spent at all no worker involved saves the remainder latency = one slice worker stays warm saves the remainder costs the warm state + 5–15 ms to replace Most abortable work sits in the left column — which is why queue-level cancellation, the cheapest mechanism to implement, is also the one that saves the most CPU in practice.
Implement left to right. A pool that only knows how to 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.

Shared memory requires cross-origin isolation

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.


CPU spent on eight keystrokes with and without cancellation Two dispatch policies over eight filter jobs of 180 milliseconds each. Without cancellation the pool runs all eight, spending about 1.44 seconds of CPU on seven results nobody will see. With superseded jobs dropped before dispatch, only the newest runs, spending 180 milliseconds, and the final result arrives around 340 milliseconds sooner because it is not queued behind stale work. Eight keystrokes, one useful result run everything ≈1.44 s — seven wasted results drop superseded jobs ≈180 ms, and the answer arrives sooner 0 0.5 1.0 1.5 s The saving comes entirely from the queue, before a worker has touched the job.
Queue-level cancellation is the cheapest mechanism to implement and the one that saves the most.

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.


Where a cancellation is observed, by mechanism Three observation points. A queued job is dropped by the dispatcher with no worker involvement. A running task observes a cancel message only when it yields to the event loop, so the check interval sets the latency. A running task can also read a shared-memory flag mid-loop without yielding at all, which gives sub-millisecond latency where cross-origin isolation is available. How the worker finds out queued the dispatcher drops it no worker involved running, message seen at the next yield interval sets latency running, shared flag read mid-loop, no yield sub-millisecond The middle mechanism is portable; the right-hand one needs cross-origin isolation. Each step is cheap; skipping one is what makes the next expensive.
Latency is a design choice here: it equals whatever interval the task uses to look.

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.

Frequently Asked Questions

Can an AbortSignal be passed to a worker directly?
No — AbortSignal is not structured-cloneable, so it cannot cross the boundary. Plumb it instead: keep the signal on the page, and on abort post a CANCEL message carrying the job id. The worker checks a flag between slices of work. For a worker that must see the cancel without draining its message queue, an Int32Array in a SharedArrayBuffer gives a flag readable mid-loop.
Is terminate() an acceptable way to cancel?
Sometimes, and it is the only mechanism that stops a task with no yield points. The cost is everything the worker held: parsed data, compiled WebAssembly, warm caches, plus 5–15 ms to construct a replacement. Terminate is reasonable for a dedicated single-job worker, and expensive for a pool member that was holding a loaded dataset.

See also