vue-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 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.signalandctx.yield()in long loops, orhardCancelOnAbortto terminate the worker thread;run()rejects immediately regardless of what the worker is doing - Concurrency modes —
parallel,latest(a new run supersedes older ones) orserial(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 —
WorkerHandlerErrorcarriescode,detailsand acausechain to the main thread - 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
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
hardCancelOnAbortcancels 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) — everyrun()starts at once.'latest'— a new run supersedes the previous ones: they reject with anAbortErrorwhosereasonisWORKER_SUPERSEDED(check withisSupersededError()). It is never written toerror, and your ownsignalis not aborted. WithhardCancelOnAbortthe 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 nextrun()creates a new one.pool.terminate()closes the pool. Waiting and running tasks, and any laterrun()/call()/map(), reject with aWorkerErrorwhosecodeis'POOL_TERMINATED'(not anAbortError).warmup()does nothing. Callpool.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 aDataCloneErrornames the path to the first proxy.useWorkerComputed(factory, source, { worker, immediate, refresh }).- Pool:
idleTimeout,hardCancelOnAbort(also per call),affinity/affinityMode. chunksis 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
- 📖 Full documentation: npm.vuecraft.ru/en/packages/vue-worker-kit
- 🌐 VueCraft: vuecraft.ru/en
- 👤 Author: macrulez.ru/en
- 💻 GitHub: macrulezru/vue-worker-kit
- 📦 NPM: vue-worker-kit
- 🐛 Issues: github.com/macrulezru/vue-worker-kit/issues
License
MIT
💖 Support the project
Open source takes time and effort. If this library saves you time or brings value, consider supporting further development.
Thank you for being part of this journey. ❤️
Related Packages
SSR-safe Vue 3 composables and directives for Anime.js v4 — reactive animations...
Vue micro query — ~4KB gz query/infinite-query/mutation composables for Vue 3, w...
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 | |
| 0.2.2 | ^3.4.0 | MIT | |
| 0.2.1 | ^3.4.0 | MIT | |
| 0.2.0 | ^3.4.0 | MIT | |
| 0.1.3 | ^3.4.0 | MIT | |
| 0.1.2 | ^3.4.0 | MIT | |
| 0.1.1 | ^3.4.0 | MIT | |
| 0.1.0 | ^3.4.0 | MIT |