Advanced Features
Transferables
Into the worker, via RunOptions.transfer:
const buffer = new ArrayBuffer(1024 * 1024)
const result = await run(buffer, { transfer: [buffer] })
// buffer.byteLength === 0 immediately — it was detached, not copiedBack out of the worker, via ctx.transfer(...) — the mirror of the above, for a handler that wants to hand back a large buffer (e.g. a resized image, an OffscreenCanvas-rendered frame) without copying it:
// resize.worker.ts
export default defineWorkerHandler((input: ResizeInput, ctx) => {
const output = resize(input) // produces a fresh ArrayBuffer
ctx.transfer(output) // sent back zero-copy instead of structured-clone copied
return output
})ctx.transfer() doesn't require the transferred object to be part of the returned value — call it with whatever transferables should ride along with the result. Safe to call more than once; every object passed across all calls is included.
Streaming / Chunked Results
For large datasets where you want intermediate results without waiting for full completion:
import { useWorker } from 'vue-worker-kit'
const { run, chunks, isRunning } = useWorker<typeof import('./process.worker')>(
() => new Worker(new URL('./process.worker.ts', import.meta.url), { type: 'module' }),
{ streaming: true }, // required — without it `chunks` is `undefined`, not a ref
)
// Process large dataset with streaming results
const finalResult = await run(largeDataset)
// chunks.value contains all intermediate results as they arrive
watch(chunks, (newChunks) => {
console.log('Received chunk:', newChunks[newChunks.length - 1])
})Worker-side:
// process.worker.ts
import { defineWorkerHandler } from 'vue-worker-kit/worker'
export default defineWorkerHandler(async (items: LargeDataset[], ctx) => {
const results: Result[] = []
for (let i = 0; i < items.length; i += 100) {
const batch = items.slice(i, i + 100)
const processed = await processBatch(batch)
// Send intermediate result immediately
ctx.reportChunk(processed)
results.push(...processed)
if (ctx.signal.aborted) throw ctx.signal.reason
}
return results // Final result
})chunks: ShallowRef<unknown[]>— reactive array of all reported chunksctx.reportChunk(data)— sends partial result to main thread (unthrottled)- Chunks accumulate in order; final result is separate from chunks
- Useful for progressive rendering, real-time updates, or memory-efficient processing
If streaming is left false (the default) but the handler calls ctx.reportChunk() anyway, the chunk is dropped — there's no chunks ref for it to land in. In development mode you'll get a one-time console.warn per run pointing this out; in production it stays silent. If you're seeing that warning unexpectedly, either pass { streaming: true }, or, if this worker runs through createWorkerPool()/useWorkerPool(), pass onChunk to run()/map() instead — see Worker Pool.
Cancellation
const controller = new AbortController()
const promise = run(input, { signal: controller.signal })
controller.abort() // promise rejects with AbortError, immediately — regardless of what the worker doesIf you don't pass your own signal, run() creates one internally; cancel() aborts it. retries never applies to an aborted run.
Retry Strategy with Backoff
For transient failures, configure automatic retries with exponential backoff:
import { useWorker } from 'vue-worker-kit'
const { run, error } = useWorker<typeof import('./api.worker')>(
() => new Worker(new URL('./api.worker.ts', import.meta.url), { type: 'module' }),
{
retries: 3,
retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 10000), // 1s, 2s, 4s, capped at 10s
},
)
// On failure, automatically retries with increasing delay
const result = await run(data)retries: number— max retry attempts (default:0, no retries)retryDelay: number | ((attempt: number) => number)— delay between retries- If
number: constant delay in ms - If function: dynamic delay based on attempt number (1-indexed)
- If
- Retries only apply to non-abort errors (
AbortErrorrejects immediately) - Common pattern: exponential backoff with jitter for API calls or flaky operations
// Advanced: exponential backoff with random jitter
{
retries: 5,
retryDelay: (attempt) => {
const baseDelay = 1000 * 2 ** attempt
const jitter = Math.random() * 1000
return Math.min(baseDelay + jitter, 30000)
},
}Memoization / Result Cache
For pure worker functions (same input → same output), enable LRU caching:
import { useWorker } from 'vue-worker-kit'
const { run } = useWorker<typeof import('./hash.worker')>(
() => new Worker(new URL('./hash.worker.ts', import.meta.url), { type: 'module' }),
{
cache: { cache: 'lru', maxCacheSize: 100 }, // keep the last 100 results
},
)
// First call — executes in worker
const hash1 = await run(data)
// Second call with the same input (compared via JSON.stringify) — returns the cached
// result instantly, no worker invocation, no postMessage round-trip
const hash2 = await run(data) // hash1 === hash2Options (cache: UseWorkerCacheOptions):
cache: 'lru'— enable the LRU cache (unset/omitted disables it)maxCacheSize: number— max entries before evicting the oldest (default:50)
The cache key is JSON.stringify(input) (exported as createCacheKey() if you want to reason about collisions yourself) — inputs that stringify the same (including object key order) share a cache entry.
useWorkerComputed() doesn't have a cache option — its own generation-number mechanism already discards stale/superseded results, and its source() typically produces a fresh input on every reactive tick anyway, so key-based memoization wouldn't have much to hit.
Error handling
- A thrown error inside the handler is serialized as
{ name, message, stack }and reconstructed on the main thread as aWorkerError..workerStackis the original in-worker stack;.causeis a synthetic error created at therun()call site (before crossing into the worker) — so both ends of the failure show up together in the console/Sentry. - A protocol-level failure (e.g. an object that doesn't structured-clone) becomes a
WorkerErrorwithname: 'DataCloneError', not an unhandled exception. WorkerUnavailableErroris thrown instead of a rawReferenceError: Worker is not definedwhenrun()is called somewhere with no globalWorker(typically SSR) — it is never wrapped or retried.
Worker lifecycle
- Idle timeout — a worker idle longer than
idleTimeoutis terminated; the nextrun()transparently spins up a new one (small latency on the first call after idling — expected). - Scope-based auto-termination —
useWorker/useWorkerPoolcalled insidesetup()terminate their worker(s) ononScopeDispose, avoiding the classic SPA-navigation leak. - Pool workers are lazy — created as tasks arrive, up to
size, not all atcreateWorkerPool()time.
Warmup
To avoid cold-start latency on the first task, you can pre-create workers without executing any work:
// Single worker
const { warmup, run } = useWorker<typeof import('./x.worker')>(
() => new Worker(new URL('./x.worker.ts', import.meta.url), { type: 'module' }),
)
await warmup() // Worker is now instantiated and ready
const result = await run(data) // No worker creation delay
// Pool - pre-create all workers up to size
const pool = createWorkerPool<typeof import('./resize.worker')>(
() => new Worker(new URL('./resize.worker.ts', import.meta.url), { type: 'module' }),
{ size: 4 },
)
await pool.warmup() // All 4 workers are now instantiated
const results = await pool.map(items) // Immediate execution, no cold startsWarmup is useful when you know a worker-intensive operation is about to happen (e.g., user clicks "Process" button) and you want to eliminate the ~50-200ms worker creation latency. Call it during idle time (e.g., onMounted, or after initial page load) to keep interactions snappy.
Benchmarks
npm run benchmark (benchmark/heavy-computation.bench.ts, via tinybench) compares a CPU-bound task (naive recursive fib(30..34)) run on the main thread vs. a single worker thread vs. a 4-thread pool — via node:worker_threads, since this runs under Node (tsx), not a browser. It's a sanity check of the pool's real parallel speedup, not a benchmark of this package's own composables (those add negligible overhead on top of raw postMessage, which is what's actually being measured here).
Example run on this machine (results vary by hardware/load — run it yourself for numbers that mean anything on your machine):
| Task | Ops/sec | Avg time |
|---|---|---|
| Main thread | 7.4 | 135ms |
| Single worker thread | 5.8 | 173ms |
| Pool of 4 worker threads | 9.1 | 111ms |
Single-worker is slower than main thread here — expected: postMessage/thread-startup overhead on a task that isn't parallelized. The pool is faster because 8 tasks genuinely run across 4 threads at once, not because any one worker is faster than the main thread.