Skip to content

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/Ofetch, 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 the await. No worker needed here, ever.
  • A CPU-bound computation — your own loop, a sort, a parse. Wrapping it in an async function changes 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 what defineWorkerHandler's ctx.reportProgress/ctx.signal pattern 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

bash
npm install vue-worker-kit

No peer dependencies beyond vue itself.

Quick start

ts
// 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)
})
ts
// 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 needed

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.