Skip to content

Cancellation, Concurrency & Operations ​

Cooperative cancellation in long loops ​

A synchronous loop inside a handler blocks its worker thread: the worker cannot receive the cancel message until the loop ends. run() still rejects at once on the main side, but the CPU stays busy.

ts
import { defineWorkerHandler } from 'vue-worker-kit/worker'

export default defineWorkerHandler(async (rows: Row[], ctx) => {
  const out = []
  for (let i = 0; i < rows.length; i++) {
    out.push(heavy(rows[i]))
    if (i % 1000 === 0) await ctx.yield()
  }
  return out
})
  • await ctx.yield() — gives control back to the event loop so a pending cancel can arrive, then throws if the run was aborted.
  • ctx.throwIfAborted() — only the check, without yielding.

WARNING

A pool without hardCancelOnAbort cancels running tasks cooperatively only. A queued task is rejected immediately; a running one keeps its slot until the handler yields or finishes.

How often to call yield() ​

Call it every few milliseconds of work, not on every iteration. Measured in Chromium, Firefox and WebKit with the cancel-yield.html stand from the demo: with about 4 ms of work between yield() calls, the loop stops after at most two more yield() calls in 100 of 100 attempts in all three browsers. One yield() costs about 20 µs in Chromium and Firefox and 0.3–0.4 ms in WebKit.

The cancel message needs a moment to cross from the main thread to the worker. Yielding much more often than that moment (every ~0.3 ms) only adds calls to the count: in Firefox up to eight yield() calls were seen before the loop stopped, although it still stopped within milliseconds. A good rule is 1–5 ms of work between yields.

Hard cancel ​

hardCancelOnAbort: true terminates the worker thread instead of waiting for the handler.

  • Options:
    • useWorker() — instance option. The worker is terminated on abort and the next run() creates a new one.
    • Pool — pool option, which RunOptions.hardCancelOnAbort / WorkerMapOptions.hardCancelOnAbort can override for one call (the call wins). Only the slot of the cancelled task is terminated; a map() with a shared signal terminates every busy slot.
    • useSharedWorker() — not available: one tab cannot kill a worker other tabs use.

Concurrency modes ​

ts
const search = useWorker<typeof import('./search.worker')>(factory, {
  concurrency: 'latest',
  hardCancelOnAbort: true,
})
  • Options:
    • 'parallel' (default) — every run() starts at once.
    • 'latest' — a new run supersedes the previous ones. They reject with an AbortError whose reason is WORKER_SUPERSEDED; check it with isSupersededError(). The error is never written to error, and the caller's own signal is not aborted. With hardCancelOnAbort the busy worker is terminated and recreated.
    • 'serial' — runs go one by one; cancel() aborts the current run and clears the queue.

Typed operations ​

ts
// tools.worker.ts
export default defineWorkerOperations({
  parse: (input: { text: string }) => ({ length: input.text.length }),
  format: async (input: { value: number }) => `v=${input.value}`,
})

// main
const tools = useWorker<typeof import('./tools.worker')>(factory)
const parsed = await tools.call('parse', { text: 'hello' })

Names, inputs and outputs are inferred. It works with useWorker, pools and useSharedWorker. An unknown name fails with code: 'UNKNOWN_OPERATION'. The cache key includes the operation name. A module made with defineWorkerOperations() has call() and no run().

Errors with data ​

ts
throw new WorkerHandlerError('Bad row', { code: 'E_ROW', details: { row: 7 } })

code and details arrive on the WorkerError, together with a workerCause chain (up to three levels). If details cannot be structured-cloned, it is replaced by a string and detailsDropped is true.

Per-call callbacks and unwrap ​

  • run(input, { onProgress, onChunk }) — callbacks for one call, next to the shared refs.
  • unwrap: 'shallow' | 'deep' — 'deep' copies nested reactive proxies before posting. In dev a DataCloneError names the path to the first proxy.

Pool: terminate, restart, idle slots and affinity ​

  • pool.terminate() closes the pool. Waiting and running tasks, and any later run()/call()/map(), reject with a WorkerError whose code is 'POOL_TERMINATED' (not an AbortError). pool.restart() opens it again. A single worker's terminate() is different: it only releases the worker, and the next run() creates a new one.
  • idleTimeout — terminates idle slots.
  • RunOptions.affinity with pool affinityMode: 'prefer' | 'strict' keeps tasks with the same key on the same slot; the rules are on the Worker Pool page.

Which one to use ​

NeedUse
One heavy task at a time, reactive stateuseWorker
Live input, only the newest result mattersuseWorker with concurrency: 'latest' and hardCancelOnAbort
Many independent small tasks across corescreateWorkerPool
A value derived from reactive stateuseWorkerComputed
One computation shared by all tabsuseSharedWorker