@jasonkneen/unithread
v0.2.2
Published
Isomorphic true-multithreading for JavaScript: one API over node:worker_threads and Web Workers. Function shipping, worker pools, RPC proxies, shared memory + Atomics.
Maintainers
Readme
unithread
True multithreading for JavaScript with one API across Node and the browser. Wraps node:worker_threads and Web Workers behind a single surface: function shipping, worker pools, RPC services, and shared memory via SharedArrayBuffer + Atomics.
import { runInThread, Task, WorkerPool, SharedCounter, Mutex, Signal } from "@jasonkneen/unithread";
// One-shot: run a function on a real OS thread
const n = await runInThread((x) => {
const fib = (i) => (i < 2 ? i : fib(i - 1) + fib(i - 2));
return fib(x);
}, 35);
// Persistent thread, call repeatedly
const task = await Task.spawn((a, b) => a + b);
await task.run([2, 3]); // 5
await task.terminate();
// RPC service: object of named methods on one thread
const svc = await Task.spawnService({
square: (x) => x * x,
hash: (s) => { /* self-contained work */ },
});
await svc.call("square", [12]); // 144
// Pool sized to hardware cores, order-preserving parallel map
const pool = await WorkerPool.create((n) => heavyWork(n));
const results = await pool.map(inputs, (n) => [n]);
await pool.close();
// True shared memory across threads
const counter = new SharedCounter(0); // Atomics-backed
const mutex = new Mutex(); // lock() / lockAsync() / withLock()
const signal = new Signal(); // wait() / waitAsync() / fire()
await runInThread((buf) => {
new Int32Array(buf); Atomics.add(new Int32Array(buf), 0, 1);
}, counter.buffer);How it works
- Node:
new Worker(source, { eval: true })fromnode:worker_threads. - Browser:
new Worker(URL.createObjectURL(new Blob([source]))). - One env-detecting bootstrap script runs in both worker types and speaks a small id-correlated message protocol (call / method / result / error), so
Task,WorkerPool, and RPC behave identically in either runtime.
Rules & caveats (the honest ones)
- Shipped functions must be self-contained. They cross the thread boundary via
Function.prototype.toString()— no closure captures, no imports from outer scope. Pass data as arguments; useSharedArrayBufferfor shared state. - Browser
SharedArrayBufferrequires cross-origin isolation (COOP/COEP headers).Atomics.waitis forbidden on the browser main thread — uselockAsync()/waitAsync()there. Node has no such restriction. - Everything non-shared is copied (structured clone). Pass
ArrayBuffers in thetransferlist to move instead of copy. - Every function shipped via
runInThread,Task.spawn, orWorkerPool.createreceives one extra trailing argument: aWorkerEnv({ isMainThread, threadId, runtime, emit, transfer }).Task.spawnServicemethods are the one exception — they're invoked as plain methods (task.call(name, args)) with no env appended, so reach forglobalThis.__unithreadinside a service method instead.
Drop-in usage
Node / bundlers — npm install @jasonkneen/unithread, then import { ... } from "@jasonkneen/unithread".
Browser, zero build step — copy dist/unithread.bundle.min.js (8.5 kB) next to your page:
<script type="module">
import { runInThread, WorkerPool, spawnRemote } from "./unithread.bundle.min.js";
const svc = await spawnRemote({ square: (x) => x * x });
console.log(await svc.square(12)); // 144 — computed on a real thread
await svc.terminate();
</script>For SharedArrayBuffer in the browser, serve with COOP/COEP (see demo/serve.mjs for a 30-line reference server):
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corpEverything except shared memory (spawn, pools, RPC proxies, transfers) works without those headers.
Verified (this build)
Node 24 — 21/21 (npm test) and real Chromium via Playwright — 11/11 (npm run test:browser), including the proofs that are impossible without independent OS threads:
- main thread timers kept firing (~42 ticks) during a 400 ms synchronous worker spin — both runtimes
- Node main thread blocked on
Atomics.wait, released by a worker'snotify; browser main thread same viawaitAsync - 4 threads × 50 k atomic increments = exactly 200 000 on one
SharedArrayBuffer— both runtimes - mutex-serialized non-atomic read-modify-write exact across 4 threads — both runtimes
- zero-copy
ArrayBuffertransfer worker→main — both runtimes - browser suite ran cross-origin-isolated (
crossOriginIsolated === true)
Drop-in options
- npm tarball:
npm i ./unithread-0.2.0— full package with types. - Single file: copy
dist/unithread.bundle.js(13 kB, zero deps) or.min.js(8.5 kB) into your project;import { ... } from "./unithread.bundle.js"works in Node and browsers unchanged. - Browser check:
npm run demo→ http://localhost:8080. Serves with COOP/COEP already set, so theSharedArrayBufferproofs run rather than skip.
v0.2 additions
spawnRemote(methods)/wrap(task)— Comlink-style proxy:await svc.pow(2, 10).- Transferables both directions: pass a transfer list to
run/call; return viaenv.transfer(value, [buffers]). Moved, not copied — source detaches. - Streaming: worker calls
env.emit(event, data)mid-task; main thread subscribes withtask.onEvent(cb). Also onglobalThis.__unithreadinside service methods.
Agent skill
skill/ packages unithread as a Claude Code skill: an agent can audit a codebase for
main-thread hogs, offload them to threads, and prove the main thread was freed.
ln -s "$PWD/skill" ~/.claude/skills/unithreadThen ask an agent to "make this app stop janking". It will measure candidates, refuse to offload anything under ~16 ms, verify the shipped function captures nothing, and report a before/after — reverting if the number did not move.
Build / test / demo
npm install && npm run build # tsc -> dist/, then esbuild -> dist/unithread.bundle[.min].js
npm test # node suite — 21/21
npm run test:browser # chromium suite — 11/11 (needs: npx playwright install chromium)
npm run test:skill # skill suite — 28/28 (portability + measurement gates)
npm run demo # http://localhost:8080, served with COOP/COEPnpm run demo opens a live page with one switch on it. The same workload (fib(34) on a
loop, ~110 ms a job) runs non-stop; the switch moves it between the main thread and a pool of
worker threads, and a frame-time trace shows the result immediately:
| work runs on | fps | worst frame | jobs/sec | | --- | --- | --- | --- | | main thread | ~16 | ~133 ms | 7 | | 4 worker threads | ~59 | ~18 ms | 13 |
Measured on a 16-core M-series in Chromium. The frame trace goes from a solid wall of red (every frame missing the 16.7 ms budget) to solid green the instant you flip it.
