Worker Kit
Type-safe Web Worker composables for Vue 3 — useWorker(), a worker pool, and a reactive useWorkerComputed(), with input/output types inferred straight from the worker file itself. Zero runtime dependencies beyond Vue.
Features
useWorker()— type-safe composable wrapping a single Web Worker; input/output types inferred fromtypeof import('./x.worker'), no manual generics on either sidecreateWorkerPool()/useWorkerPool()— a lazily-grown pool (default size:navigator.hardwareConcurrency) for many small, independent tasks;pool.run()/pool.map()with bounded concurrencyuseWorkerComputed()— acomputed()that recalculates inside a worker on every reactive source change; superseded/stale runs are discarded automatically via a generation numberuseSharedWorker()— reuses oneSharedWorkeracross every tab/window of the same origin, instead of one worker per tabdefineWorkerHandler()— worker-side helper that wires the run/cancel message protocol automatically; you only write the handler function- Transferables — zero-copy transfer into the worker (
RunOptions.transfer) and back out (ctx.transfer()), for large buffers like images orOffscreenCanvasframes - Streaming / chunked results —
ctx.reportChunk()sends intermediate results while a long task is still running, collected reactively inchunks - Cancellation —
AbortSignalsupport, cooperative viactx.signal;run()rejects immediately regardless of what the worker is doing - Retry with backoff —
retries/retryDelayoptions retry non-abort failures automatically, with a constant or attempt-based delay - Memoization / result cache — opt-in LRU cache keyed by
JSON.stringify(input), skips the worker round-trip entirely for a repeated input - Warmup — pre-create a worker (or a whole pool) ahead of time to eliminate the ~50-200ms cold-start latency on the first real task
- Worker lifecycle management — idle-timeout termination, scope-based auto-termination on
onScopeDispose(no SPA-navigation leaks), lazily-created pool workers - Devtools panel —
vue-worker-kit/devtools's<WorkerActivityPanel>shows busy/idle counts, queue length, average task time, and recent errors — no@vue/devtools-apidependency - Structured error handling — a thrown worker error becomes a
WorkerErrorwith the original in-worker stack preserved as.workerStack; aDataCloneErrorfor values that can't structured-clone; aWorkerUnavailableErrorinstead of a rawReferenceErrorunder SSR - End-to-end type inference — the worker function's input/output type is inferred from the worker file itself via
typeof import(...), not duplicated by hand on both sides - Zero runtime dependencies beyond Vue
The problem
Existing Vue wrappers around Web Workers (vue-worker, vue-web-workers, and similar) are Vue 2-era plugins: no types, no Composition API, no pool, no transferables, disposable workers built by serializing a function to a string. Comlink gives you a solid RPC protocol, but typing it is manual (Comlink.wrap<MyAPI>()), with no Vue reactivity and no component-lifecycle integration.
This package's one distinguishing idea: end-to-end typing without duplicating generics. The worker function's input/output type is inferred from the worker file itself via typeof import(...), not written out by hand on both sides.
async/await vs. a real thread
Worth being explicit about, because it's easy to assume async/await already solves this: async/await does not move work off the JS thread. JavaScript (outside of workers) always runs on a single thread, regardless of how much async/await you sprinkle on it.
There are two genuinely different situations people call "async":
- Waiting on I/O —
fetch,setTimeout, any promise backed by a browser/OS API. The actual waiting happens outside JS (in the network stack, the OS timer), so the main thread really is free during theawait. No worker needed here, ever. - A CPU-bound computation — your own loop, a sort, a parse. Wrapping it in an
async functionchanges nothing: the loop still runs synchronously, on the same thread that's also trying to render your UI and handle clicks. The only way to keep the UI responsive without a worker is to manually chop the loop into pieces and yield (await new Promise(r => setTimeout(r))) between them — which is exactly whatdefineWorkerHandler'sctx.reportProgress/ctx.signalpattern is for inside a worker, but doesn't buy you anything if you do it on the main thread instead: it's still the same thread, just interleaving smaller slices of the same total work with rendering.
A Worker is a genuinely separate OS thread. That's the actual, structural difference from async/await:
- The main thread is 100% free for the entire computation — no manual chunking/yielding required just to keep the UI alive (you'd still chunk if you want progress reporting or cancellation, but that's optional, not load-bearing for responsiveness).
- It is not automatically faster in wall-clock terms —
postMessage/structured-clone and worker startup have real cost, and for a short computation a plain main-thread run can easily finish sooner. The point of a worker isn't raw speed; it's that the work no longer competes with your UI for the same thread.createWorkerPool()is the one place where you do get real speed from parallelism — multiple workers genuinely computing on different CPU cores at once.
How the type inference works
typeof import('./heavy-sort.worker') is a type-only expression — TypeScript erases it at compile time. It does not import the worker file's code into the main bundle; the worker is only ever loaded via new URL(..., import.meta.url), as its own chunk. defineWorkerHandler() returns a phantom-typed marker (__input/__output fields that never exist at runtime); useWorker/createWorkerPool read In/Out off of that marker through a conditional type. The result: run()'s signature is exactly (input: In, options?: RunOptions) => Promise<Out>, without either side writing a manual generic for the data shape.