Offload any heavy computation to a background thread with one line of code. No separate files. No postMessage wiring. No type safety lost.
Every heavy calculation runs on the same thread as your UI. The standard fix requires separate files, manual wiring, and zero type safety — so most developers just skip it.
// ① Separate file you must maintain // worker.ts self.addEventListener('message', (e) => { const result = e.data * e.data; self.postMessage(result); }); // ② Manual wiring in main.ts — no types, no promises const worker = new Worker(new URL('./worker.ts', import.meta.url)); worker.postMessage(42); worker.addEventListener('message', (e) => { console.log(e.data); // any type. no autocomplete. good luck. }); // ③ Don't forget to .terminate() or you have a memory leak. Forever.
FluxWorker serializes your function, spawns a Worker from a Blob URL, and returns a fully-typed Promise. The Worker terminates itself when done.
import { createWorker, jankMonitor } from 'fluxworker'; // Any pure function — no changes needed const heavyFib = (n: number): number => { if (n <= 1) return n; return heavyFib(n - 1) + heavyFib(n - 2); }; // Wrap it — that's literally it const workerFib = createWorker(heavyFib); // ^ TypeScript infers: (n: number) => Promise<number> 🎯 // Call it just like before — but async const result = await workerFib(42); // UI never froze. Worker terminated. Blob URL revoked. Clean. // Optional: dev-mode jank detection if (import.meta.env.DEV) jankMonitor.enable();
Both buttons compute fib(42) — a ~2 second CPU task. Watch the bouncing ball. That ball is your UI thread.
Thread: Shared with your computation
Ball freezes while fib(42) runs.
Thread: Free — UI stays live
Ball keeps moving while fib(42) runs.
A micro-library that solves one problem with ruthless focus.
createWorker(fn) returns a typed async version of your function — arguments and return type inferred automatically.Compared against Comlink, the current community standard.
| Feature | FluxWorker | Comlink | Raw Worker |
|---|---|---|---|
| Zero separate files | Yes | Needs worker file | Needs worker file |
| Bundle size | <2KB | ~5KB | 0KB |
| Promise / async-await | Native | Via Proxy | Manual callback |
| TypeScript inferred | Automatic | Manual types | None |
| Auto cleanup on done | Yes | Manual .terminate() | Manual |
| Jank detection | Built-in | None | None |
| SSR / no-Worker fallback | Yes | Throws | Throws |
| SharedArrayBuffer | Yes | Yes | Yes |
Every call through createWorker passes through this pipeline.
This is the real implementation. No magic. No dependencies.
type AnyFn = (...args: any[]) => any; export type WorkerFn<T extends AnyFn> = (...args: Parameters<T>) => Promise<Awaited<ReturnType<T>>>; export const jankMonitor = { _enabled: false, enable() { this._enabled = true; }, disable() { this._enabled = false; }, measure<T>(name: string, callFn: () => T): T { if (!this._enabled) return callFn(); const t0 = performance.now(); const result = callFn(); const ms = performance.now() - t0; if (ms > 16) console.warn(`[FluxWorker] "${name}" blocked ${ms.toFixed(1)}ms — wrap it.`); return result; } }; export function createWorker<T extends AnyFn>(fn: T): WorkerFn<T> { if (typeof Worker === 'undefined') return ((...args) => { try { return Promise.resolve(fn(...args)); } catch(e) { return Promise.reject(e); } }) as WorkerFn<T>; return function(...args) { return new Promise((resolve, reject) => { // ① Serialize const src = `const fn=${fn.toString()};self.onmessage=e=>{try{const r=fn(...e.data.args);r?.then?r.then(v=>self.postMessage({id:e.data.id,result:v})).catch(err=>self.postMessage({id:e.data.id,error:String(err)})):self.postMessage({id:e.data.id,result:r})}catch(err){self.postMessage({id:e.data.id,error:String(err)})}};`; const url = URL.createObjectURL(new Blob([src], {type:'application/javascript'})); const worker = new Worker(url); const id = Math.random().toString(36).slice(2); // ② Bridge worker.postMessage({ id, args }); // ③ Guard + ④ Clean const done = (ok: boolean, val: any) => { worker.terminate(); URL.revokeObjectURL(url); ok ? resolve(val) : reject(new Error(val)); }; worker.onmessage = e => done(!e.data.error, e.data.error ?? e.data.result); worker.onerror = e => done(false, e.message); }); } as WorkerFn<T>; }
The build plan — commit after each phase.