vue-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.
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.
Installation
npm install vue-worker-kitNo peer dependencies beyond vue itself.
Quick start
// heavy-sort.worker.ts
import { defineWorkerHandler } from 'vue-worker-kit/worker'
export default defineWorkerHandler(async (data: number[], ctx) => {
for (let i = 0; i < data.length; i++) {
if (ctx.signal.aborted) throw ctx.signal.reason
if (i % 10_000 === 0) ctx.reportProgress(i / data.length)
}
return data.sort((a, b) => a - b)
})// component setup()
import { useWorker } from 'vue-worker-kit'
const { run, isRunning, progress, error, cancel } = useWorker<typeof import('./heavy-sort.worker')>(
() => new Worker(new URL('./heavy-sort.worker.ts', import.meta.url), { type: 'module' }),
)
const sorted = await run(hugeArray, { transfer: [hugeArray.buffer] })
// sorted: number[] — inferred from heavy-sort.worker.ts, no generic annotation neededHow 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.