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.
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 nextrun()creates a new one.- Pool — pool option, which
RunOptions.hardCancelOnAbort/WorkerMapOptions.hardCancelOnAbortcan override for one call (the call wins). Only the slot of the cancelled task is terminated; amap()with a sharedsignalterminates every busy slot. useSharedWorker()— not available: one tab cannot kill a worker other tabs use.
Concurrency modes
const search = useWorker<typeof import('./search.worker')>(factory, {
concurrency: 'latest',
hardCancelOnAbort: true,
})- Options:
'parallel'(default) — everyrun()starts at once.'latest'— a new run supersedes the previous ones. They reject with anAbortErrorwhosereasonisWORKER_SUPERSEDED; check it withisSupersededError(). The error is never written toerror, and the caller's ownsignalis not aborted. WithhardCancelOnAbortthe busy worker is terminated and recreated.'serial'— runs go one by one;cancel()aborts the current run and clears the queue.
Typed operations
// 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
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 aDataCloneErrornames the path to the first proxy.
Pool: terminate, restart, idle slots and affinity
pool.terminate()closes the pool. Waiting and running tasks, and any laterrun()/call()/map(), reject with aWorkerErrorwhosecodeis'POOL_TERMINATED'(not anAbortError).pool.restart()opens it again. A single worker'sterminate()is different: it only releases the worker, and the nextrun()creates a new one.idleTimeout— terminates idle slots.RunOptions.affinitywith poolaffinityMode: 'prefer' | 'strict'keeps tasks with the same key on the same slot; the rules are on the Worker Pool page.
Which one to use
| Need | Use |
|---|---|
| One heavy task at a time, reactive state | useWorker |
| Live input, only the newest result matters | useWorker with concurrency: 'latest' and hardCancelOnAbort |
| Many independent small tasks across cores | createWorkerPool |
| A value derived from reactive state | useWorkerComputed |
| One computation shared by all tabs | useSharedWorker |