TIP
New to Workit? Start with the Overview for a quick introduction.
Task Functions
A task function receives a single typed input and returns a value (synchronously or as a Promise). Because the function is serialized via .toString() and executed in a separate global scope, it must be entirely self-contained — it cannot close over variables from the surrounding module.
import { createWorker } from '@vielzeug/workit';
import type { TaskFn } from '@vielzeug/workit';
// Inline function — keeps everything self-contained
const worker = createWorker<number, number>((n) => n * 2);
// Named function reference — also fine
function double(n: number): number {
return n * 2;
}
const worker2 = createWorker<number, number>(double);
// Type alias for reuse
type Fn = TaskFn<{ a: number; b: number }, number>;
const add: Fn = ({ a, b }) => a + b;
const addWorker = createWorker(add);Self-contained closures
The task function runs inside a Web Worker with a separate global scope. Any outer-scope variable you reference will be undefined at runtime. Put helpers inside the task function or encode them into the input payload.
Single Worker
Calling createWorker without a concurrency option creates a single worker that processes one task at a time. Additional calls to run() are queued and dispatched in order.
import { createWorker } from '@vielzeug/workit';
const worker = createWorker<string, string>((text) => text.toUpperCase());
console.log(await worker.run('hello')); // 'HELLO'
console.log(await worker.run('world')); // 'WORLD'
worker.dispose();Worker Pool
Pass concurrency to spin up multiple worker slots. Tasks are dispatched to the first idle slot; if all slots are busy the task is queued.
import { createWorker } from '@vielzeug/workit';
// Fixed pool of 4
const pool = createWorker<number, number>(
(n) => {
function fib(x: number): number {
return x <= 1 ? x : fib(x - 1) + fib(x - 2);
}
return fib(n);
},
{ concurrency: 4 },
);
// Automatically uses all 4 slots in parallel
const results = await Promise.all([30, 31, 32, 33].map((n) => pool.run(n)));
pool.dispose();
// 'auto' — uses navigator.hardwareConcurrency when available
const autoPool = createWorker<number, number>((n) => n ** 2, { concurrency: 'auto' });Queue Back-Pressure (maxQueue)
Set maxQueue to cap how many tasks can wait in the queue. When the queue is full, run() rejects with WorkerError code 'queue_full'.
import { createWorker, WorkerError } from '@vielzeug/workit';
const worker = createWorker<number, number>((n) => n * 2, {
concurrency: 1,
maxQueue: 100,
});
try {
await worker.run(1);
} catch (error) {
if (error instanceof WorkerError && error.code === 'queue_full') {
console.error('Back-pressure triggered, queue is full');
}
}Use maxQueue: 'auto' to default to concurrency * 2.
Timeouts
Set timeout (in milliseconds) to automatically reject tasks that run too long. A WorkerError with code 'timeout' is thrown.
import { createWorker, WorkerError } from '@vielzeug/workit';
const worker = createWorker<number, number>((ms) => new Promise((resolve) => setTimeout(() => resolve(ms), ms)), {
timeout: 1000,
});
try {
await worker.run(5000); // will reject after 1 s
} catch (err) {
if (err instanceof WorkerError && err.code === 'timeout') {
console.error('Task timed out');
}
}
worker.dispose();AbortSignal
Pass an AbortSignal via RunOptions to cancel a queued task before it starts. Tasks already in flight cannot be interrupted.
import { createWorker } from '@vielzeug/workit';
const worker = createWorker<string, string>((text) => text.toUpperCase(), { concurrency: 1 });
const ac = new AbortController();
// Queue multiple tasks
const p1 = worker.run('first');
const p2 = worker.run('second', { signal: ac.signal });
const p3 = worker.run('third', { signal: ac.signal });
// Cancel the queued tasks
ac.abort(); // p2 and p3 reject with DOMException (AbortError)
await p1; // still resolves — it was already in flight
worker.dispose();Transferables
Large ArrayBuffer, MessagePort, or OffscreenCanvas values can be moved to the Worker thread instead of copied. This avoids the structured-clone overhead on large payloads.
import { createWorker } from '@vielzeug/workit';
type ImageTask = { pixels: Uint8ClampedArray; width: number; height: number };
type ImageResult = { pixels: Uint8ClampedArray };
const worker = createWorker<ImageTask, ImageResult>(({ pixels }) => {
const out = new Uint8ClampedArray(pixels.length);
for (let i = 0; i < pixels.length; i += 4) {
const g = 0.299 * pixels[i] + 0.587 * pixels[i + 1] + 0.114 * pixels[i + 2];
out[i] = out[i + 1] = out[i + 2] = g;
out[i + 3] = pixels[i + 3];
}
return { pixels: out };
});
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
// Transfer the buffer — zero-copy move to the worker
const { pixels } = await worker.run(
{ pixels: imageData.data, width: imageData.width, height: imageData.height },
{ transferables: [imageData.data.buffer] },
);
worker.dispose();WARNING
Once a buffer is transferred it is detached (length = 0) in the sending context. Do not access it after the run() call.
Worker Status
The status property reflects the current state of the worker handle:
| Value | Meaning |
|---|---|
'idle' | All slots are free and waiting for tasks |
'running' | One or more slots are executing a task |
'terminated' | dispose() was called — run() will reject |
import { createWorker } from '@vielzeug/workit';
import type { WorkerStatus } from '@vielzeug/workit';
const worker = createWorker<number, number>((n) => n * 2);
console.log(worker.status); // 'idle'
const p = worker.run(21);
console.log(worker.status); // 'running'
await p;
console.log(worker.status); // 'idle'
worker.dispose();
console.log(worker.status); // 'terminated'Stats
Use lightweight counters for visibility and load monitoring:
completed: successful tasks since creationutilization: active slot ratio from0to1size: queued task count
import { createWorker } from '@vielzeug/workit';
const pool = createWorker<number, number>((n) => n + 1, { concurrency: 4 });
console.log(pool.completed); // 0
console.log(pool.utilization); // 0
console.log(pool.size); // 0
const p = pool.run(1);
console.log(pool.utilization); // > 0 while running
await p;
console.log(pool.completed); // 1Graceful Shutdown (close)
Use close() to finish queued/in-flight work before terminating workers:
import { createWorker } from '@vielzeug/workit';
const worker = createWorker<number, number>((n) => n * 2, { concurrency: 1 });
const p1 = worker.run(1);
const p2 = worker.run(2);
await worker.close();
await p1;
await p2;
console.log(worker.status); // 'terminated'Use dispose() for immediate forceful termination.
Runtime Availability
createWorker() is safe to call in any runtime. Actual execution happens only when you call run(), and that requires a real Worker implementation.
import { createWorker, WorkerError } from '@vielzeug/workit';
const worker = createWorker<number, number>((n) => n * 2);
try {
console.log(await worker.run(21));
} catch (error) {
if (error instanceof WorkerError) {
console.error('Worker execution is unavailable in this runtime');
}
}This keeps construction cheap and predictable in shared modules, while still failing clearly when the runtime cannot execute Workers.
Symbol.dispose / using Declarations
WorkerHandle implements [Symbol.dispose] as an alias for dispose(), enabling the TC39 explicit resource management using keyword (TypeScript ≥ 5.2 with "lib": ["es2025"]):
import { createWorker } from '@vielzeug/workit';
{
using worker = createWorker<number, number>((n) => n * 2);
const result = await worker.run(21); // 42
} // worker.dispose() is called automatically hereThis also works with worker pools:
{
using pool = createWorker<string, string>((text) => text.toUpperCase(), { concurrency: 4 });
const results = await Promise.all(['hello', 'world'].map((s) => pool.run(s)));
// ['HELLO', 'WORLD']
} // all 4 slots terminated automaticallycreateTestWorker supports [Symbol.dispose] as well:
import { createTestWorker } from '@vielzeug/workit/test';
{
using worker = createTestWorker<number, number>((n) => n * 3);
const result = await worker.run(7); // 21
} // disposed automaticallyTesting
Use createTestWorker from the /test subpath to run tasks in-process with call recording. Workers never spawn, so tests run in any environment (Node, jsdom, etc.) without additional setup.
import { createTestWorker } from '@vielzeug/workit/test';
import { describe, expect, it } from 'vitest';
type Input = { a: number; b: number };
type Output = number;
describe('add worker', () => {
it('returns the sum', async () => {
const worker = createTestWorker<Input, Output>(({ a, b }) => a + b);
expect(await worker.run({ a: 2, b: 3 })).toBe(5);
expect(await worker.run({ a: 10, b: 20 })).toBe(30);
// Inspect recorded calls
expect(worker.calls).toHaveLength(2);
expect(worker.calls[0]!.input).toEqual({ a: 2, b: 3 });
expect(worker.calls[1]!.output).toBe(30);
worker.dispose();
});
});TestWorkerHandle also supports [Symbol.dispose]:
{
using worker = createTestWorker<number, number>((n) => n * 2);
const result = await worker.run(21); // 42
}Framework Integration
Workit workers are plain async functions — wrap them in a hook or composable to integrate with your framework's lifecycle.
import { useEffect, useRef, useState } from 'react';
import { createWorkerPool, type WorkerPool } from '@vielzeug/workit';
function useWorkerPool<TInput, TOutput>(fn: (input: TInput) => TOutput, size = 2) {
const poolRef = useRef<WorkerPool<TInput, TOutput> | null>(null);
useEffect(() => {
poolRef.current = createWorkerPool(fn, { size });
return () => { poolRef.current?.close(); };
}, []);
return poolRef;
}
function ImageProcessor() {
const poolRef = useWorkerPool((buf: ArrayBuffer) => processImage(buf), 4);
const [result, setResult] = useState<string | null>(null);
async function handleUpload(file: File) {
const buf = await file.arrayBuffer();
const output = await poolRef.current!.run(buf);
setResult(output);
}
return <button onClick={() => handleUpload(selectedFile)}>Process</button>;
}<script setup lang="ts">
import { createWorkerPool } from '@vielzeug/workit';
import { onScopeDispose, ref } from 'vue';
const pool = createWorkerPool((n: number) => n * n, { size: 2 });
onScopeDispose(() => pool.close());
const result = ref<number | null>(null);
async function runTask(n: number) {
result.value = await pool.run(n);
}
</script>
<template>
<button @click="runTask(9)">Square</button>
<p>{{ result }}</p>
</template><script lang="ts">
import { createWorkerPool } from '@vielzeug/workit';
import { onDestroy } from 'svelte';
const pool = createWorkerPool((n: number) => n * n, { size: 2 });
onDestroy(() => pool.close());
let result: number | null = null;
async function runTask() {
result = await pool.run(9);
}
</script>
<button on:click={runTask}>Square</button>
<p>{result}</p>Pitfalls
- React: Initializing the pool with
createWorkerPool(fn, ...)directly in the component body (not insideuseEffectoruseRef) creates a new pool on every render. Always useuseReffor stable initialization. - Vue 3: Creating the pool inside a
watchorcomputedcallback instead of at the top level ofsetup()can result in multiple pools being created. Always create at the top level and registeronScopeDisposeimmediately. - Svelte: The pool created at the top of
<script>starts immediately — if the component is conditionally rendered with{#if}, the pool is created when the component mounts. This is correct. EnsureonDestroyis called to close it when the component is removed.
Working with Other Vielzeug Libraries
With Eventit
Emit progress events from a worker task and consume them on the main thread.
import { createWorker } from '@vielzeug/workit';
import { createBus } from '@vielzeug/eventit';
const bus = createBus<{ progress: number }>();
bus.on('progress', (pct) => console.log(`${pct}%`));
const worker = createWorker(async (items: string[]) => {
for (let i = 0; i < items.length; i++) {
await processItem(items[i]!);
bus.emit('progress', Math.round((i / items.length) * 100));
}
return items.length;
});With Stateit
Track worker pool status in a reactive signal to drive UI state.
import { createWorkerPool } from '@vielzeug/workit';
import { signal, computed } from '@vielzeug/stateit';
const pool = createWorkerPool(heavyTask, { size: 4 });
const stats = signal(pool.stats());
// Refresh stats after each task
const isBusy = computed(() => stats().active > 0);
async function runTask(input: number) {
const result = await pool.run(input);
stats.set(pool.stats());
return result;
}Best Practices
- Use
createWorkerPool()rather than a single worker for CPU-bound tasks — multiple workers prevent head-of-line blocking. - Set
maxQueueto bound memory usage when consumers are slower than producers. - Pass large binary data (images, audio, WASM buffers) as
Transferableto avoid copying. - Use
AbortSignalto cancel long-running tasks when the user navigates away. - Always call
close()in framework cleanup callbacks to terminate worker threads and free resources. - Keep worker task functions pure — avoid closures over mutable main-thread state since workers run in isolated threads.
- Use
createTestWorker()in unit tests to run tasks synchronously without spinning up real worker threads.