vue-worker-kit

Type-safe Web Worker composables for Vue 3 — useWorker(), worker pool, reactive useWorkerComputed(), zero dependencies, SSR-safe.
1,903
Install
npm install vue-worker-kit
Latest Version:0.3.1
Vue:3
License:MIT
Last Updated:Oct 21, 2026
Links: GitHub  ·  npm
Maintainer: macrulez

Worker Kit

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 from typeof import('./x.worker'), no manual generics on either side
  • createWorkerPool() / useWorkerPool() — a lazily-grown pool (default size: navigator.hardwareConcurrency) for many small, independent tasks; pool.run()/pool.map() with bounded concurrency
  • useWorkerComputed() — a computed() that recalculates inside a worker on every reactive source change; superseded/stale runs are discarded automatically via a generation number
  • useSharedWorker() — reuses one SharedWorker across every tab/window of the same origin, instead of one worker per tab
  • defineWorkerHandler() — 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 or OffscreenCanvas frames
  • Streaming / chunked results — ctx.reportChunk() sends intermediate results while a long task is still running, collected reactively in chunks
  • Cancellation — AbortSignal support, cooperative via ctx.signal and ctx.yield() in long loops, or hardCancelOnAbort to terminate the worker thread; run() rejects immediately regardless of what the worker is doing
  • Concurrency modes — parallel, latest (a new run supersedes older ones) or serial (one by one, cancel() clears the queue)
  • Typed operations — defineWorkerOperations() + call(name, input): several named operations in one worker, names and types inferred
  • Errors with data — WorkerHandlerError carries code, details and a cause chain to the main thread
  • Retry with backoff — retries/retryDelay options 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-api dependency
  • Structured error handling — a thrown worker error becomes a WorkerError with the original in-worker stack preserved as .workerStack; a DataCloneError for values that can't structured-clone; a WorkerUnavailableError instead of a raw ReferenceError under 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

When you'd reach for this

A UI that stutters from heavy synchronous computations is a clear sign it's time to move that work off the main thread — vue-worker-kit gives you Vue composables for exactly that, built on the real Web Worker API.

  • A 100k-row table freezes on every sort — Client-side sorting, filtering, or parsing of a large dataset runs in a real system thread — the UI stays responsive even during a heavy recalculation.
  • Live search recalculates on every keystroke — The stale run cancels itself, and a new one starts in the background thread a moment later — you can type faster than the worker computes, and the UI never stutters.
  • Bulk file or image processing in the browser — A worker pool spreads resizing, format conversion, or thumbnail generation for hundreds of files across multiple CPU cores, and large binary data moves zero-copy via transferables.
  • The same dashboard is open in several tabs — A SharedWorker computes the heavy state once for every tab instead of each tab recalculating it separately — switching tabs feels instant.

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 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. A Worker is a genuinely separate OS thread — the main thread stays 100% free for the entire computation, no manual chunking/yielding required just to keep the UI alive. createWorkerPool() is where you get real speed from parallelism — multiple workers genuinely computing on different CPU cores at once.

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. The result: run()'s signature is exactly (input: In, options?: RunOptions) => Promise<Out>, without either side writing a manual generic for the data shape.


Installation

Vue is a required peer dependency — this package has no framework-agnostic core, every entry point is a Vue composable or a Vue-consumable worker-side helper.

Environment Minimum version
Node.js ^20.19.0 || ^22.13.0 || >=24.0.0
Vue ^3.4.0 (required)
npm install vue-worker-kit

No 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 needed

More examples

Intermediate results, not just the final one

With streaming: true the worker reports chunks as it goes via ctx.reportChunk() — chunks updates reactively while the final result is still pending.

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
)

const finalResult = await run(largeDataset)

watch(chunks, (newChunks) => {
  console.log('Received chunk:', newChunks.at(-1))
})

// Worker-side calls ctx.reportChunk(data) as it processes each batch —
// chunks.value fills in progressively while run() is still pending.

Cancellation, concurrency & operations

Cooperative cancellation in long loops

A synchronous loop inside a handler blocks its worker thread: the worker cannot even receive the cancel message until the loop ends. run() still rejects at once on the main side, but the CPU stays busy. Give the loop a way out:

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() hands control back to the event loop (so a pending cancel can be delivered) and then throws if the run was aborted; ctx.throwIfAborted() does only the check. Without either call, the only way to stop a busy loop is hardCancelOnAbort: true, which terminates the worker thread.

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 demo/ (npm run test:browser): 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 far 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: aim for 1–5 ms of work between yields.

Concurrency modes

const search = useWorker<typeof import('./search.worker')>(factory, {
  concurrency: 'latest',
  hardCancelOnAbort: true,
})
  • 'parallel' (default) — every run() starts at once.
  • 'latest' — a new run supersedes the previous ones: they reject with an AbortError whose reason is WORKER_SUPERSEDED (check with isSupersededError()). It is never written to error, and your own signal is not aborted. With hardCancelOnAbort the busy worker is terminated.
  • '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. Works with useWorker, pools and useSharedWorker; an unknown name fails with code: 'UNKNOWN_OPERATION'. 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.

terminate() on a single worker and on a pool

  • useWorker().terminate() releases the worker. The next run() creates a new one.
  • pool.terminate() closes the pool. Waiting and running tasks, and any later run()/call()/map(), reject with a WorkerError whose code is 'POOL_TERMINATED' (not an AbortError). warmup() does nothing. Call pool.restart() to use the pool again.

Pool affinity

run(input, { affinity: key }) sends tasks with the same key to the same slot.

  • The key is bound to the slot that takes its first task. The binding is never moved: when 'prefer' (the default) has to use another slot because the bound one is busy, later tasks go back to the bound slot once it is free.
  • affinityMode: 'strict' makes the task wait for the bound slot and never creates extra slots for it. Other queued tasks are not blocked.
  • When the bound slot is removed (idleTimeout, a crash, hardCancelOnAbort, terminate()), the key is released and binds again to the slot that takes the next task with that key.

More options

  • run(input, { onProgress, onChunk }) — per-call callbacks next to the shared refs.
  • unwrap: 'shallow' | 'deep' — 'deep' copies nested reactive proxies before posting; in dev a DataCloneError names the path to the first proxy.
  • useWorkerComputed(factory, source, { worker, immediate, refresh }).
  • Pool: idleTimeout, hardCancelOnAbort (also per call), affinity / affinityMode.
  • chunks is notified in coalesced batches: once per animation frame while the tab is visible, by a 50 ms timer while it is hidden.
  • The result cache key is JSON.stringify(input) plus the operation name: inputs that differ only in non-serializable parts share an entry.

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

Demo and browser check

demo/ has a section for each feature above and a standalone cancel-yield.html stand. npm run test:browser runs the stand in Chromium, Firefox and WebKit (install them once with npx playwright install chromium firefox webkit).

Keeping the AI reference in sync

tests/docs.drift.test.ts checks that every public export is mentioned in the AI reference of the documentation site (packages/vue-worker-kit/ai-reference.txt). Set VWK_AI_REFERENCE to that file when it is not at the default location (../../npm-center/...). The check is skipped with a warning when the file is missing in CI, and fails locally; run it before publishing.


Documentation & links


License

MIT


💖 Support the project

Open source takes time and effort. If this library saves you time or brings value, consider supporting further development.

Donate via CryptoCloud

Thank you for being part of this journey. ❤️

Related Packages

vitedge

Vite for edge side rendering

3,277 726
@juleshry/vue-animejs

SSR-safe Vue 3 composables and directives for Anime.js v4 — reactive animations...

2,567 6
@dts/vue-uquery

Vue micro query — ~4KB gz query/infinite-query/mutation composables for Vue 3, w...

2,539
@xysfe/blitz

A simple, non-intrusive ssr framework based on Vue

3,134
vueuse-temporals

Date related composables for atmosphere-ui

1,676 1

Version History

Version Released Vue License
0.3.1 ^3.4.0 MIT
0.3.0 ^3.4.0 MIT
0.2.5 ^3.4.0 MIT
0.2.4 ^3.4.0 MIT
0.2.3 ^3.4.0 MIT