| name | unithread |
| description | Move CPU-bound work off the main thread onto real OS threads (Web Workers / node:worker_threads) via unithread. Use when a UI is janky or frozen, a page locks up, an event loop is blocked, "this loop is too slow", or the user asks to parallelise a loop, use worker threads, offload work, or make something stop blocking. |
unithread — offload CPU work onto real threads
Audit a codebase for main-thread hogs, move them onto real OS threads, and prove they moved.
One API works in both Node and browsers.
The rule that breaks everything
Shipped functions cross the thread boundary via Function.prototype.toString(). Anything they
captured from an enclosing scope does not exist on the other side. It typechecks. It lints. It
passes review. Best case it throws ReferenceError at runtime inside the worker. Worst case a
typeof guard swallows the missing identifier and it silently returns a wrong answer instead:
const MULTIPLIER = 3;
runInThread((n) => n * MULTIPLIER, 5);
const MULT = 7;
runInThread((n) => (typeof MULT !== "undefined" ? n * MULT : n), 5);
runInThread((n, m) => n * m, 5, 3);
Data crosses as arguments, transferables, or SharedArrayBuffer. Nothing else. This is why Gate 1
below does not just ask "did it throw?" — see Phase 4.
Workflow
1. Audit — measure, never guess
Find CPU-bound synchronous work: pixel/matrix loops, parse or serialise of large payloads,
hashing, compression, recursive search. Then time the candidates on representative input:
node <skill>/scripts/measure.mjs path/to/mod.mjs#exportName '[args]'
Do not offload anything under ~16 ms. The round-trip plus structured clone costs more than
the work. Present the ranked candidates with their measured costs and let the user choose.
Workers are for CPU, not I/O. Async I/O already yields; a thread buys nothing.
2. Baseline — on a quiet machine
Record the metric before changing anything.
-
Node: measure.mjs reports wallMs and worstBlockMs (the main-thread block, in ms), and
it also prints the idle event-loop noise floor before and after the run. If either floor is
over 20 ms it refuses to run (non-zero exit) rather than hand back a number — a busy machine
produces confident, wrong conclusions. Close other work and re-run. Residual limitation, stated
honestly: a burst of contention that starts and fully subsides inside the run window, gone
before the after-check samples, is invisible to both floors — this gate catches contention
present immediately before or after the run, not one confined entirely inside it.
node <skill>/scripts/measure.mjs path/to/mod.mjs#exportName '[args]'
-
Browser: --browser <url> --action "<js>" --seconds N. Also prints idleWorstFrameMs — a
frame-time baseline sampled on a blank page before navigating anywhere near url, so it
reflects whether the browser/machine is busy, not whether the page under test starts working
immediately on load. If that floor is over 30 ms it refuses to run (non-zero exit), same
posture as Node mode. Gate on jankFrames/jankPct (count and % of frames over the 16.7 ms
budget) — a clean 0%-vs-~98% split — not on worstFrameMs, which can sit close to the noise
floor. fps is printed last for reference only: headless Chromium is not vsync-capped, a
smooth run can read ~120 fps, and it must never be compared against 60.
node <skill>/scripts/measure.mjs --browser http://localhost:8080/ --action "startWork()" --seconds 4
Worked example, this repo's own demo, same workload, only the thread it runs on changes:
main thread worst frame 92-133 ms jank frames 98.9%
4-worker pool worst frame 9-18 ms jank frames 0%
3. Offload
- Copy
<skill>/assets/unithread.bundle.js into the project (e.g. src/lib/unithread.js).
Zero deps, 13 kB, same file works in Node and browsers.
- Rewrite the hot function to be self-contained — every captured value becomes a parameter.
- Pick the primitive:
runInThread(fn, ...args) — one-shot.
Task.spawn(fn) — persistent, called repeatedly.
WorkerPool.create(fn, size) + pool.map(items, toArgs) — parallel over a collection.
spawnRemote(methods) — an object of methods as a service proxy.
- Big buffers go in the transfer list — moved, not copied (the source detaches).
SharedArrayBuffer only when state is genuinely shared. In browsers it needs COOP/COEP
headers, which is a server change, not a code change.
Defaults, measured — do not invent your own:
- Pool size
min(4, cores - 1). A pool of 8 scored worse than 4 (2.0x vs 2.94x) — oversubscription.
- Keep 2 jobs in flight per worker. At depth 1 each worker idles through the result
round-trip (8.4 vs 12.4 jobs/sec).
4. Prove — both gates, no exceptions
Gate 1 — portability. Runs the candidate on both the main thread and in a real worker,
with the same args, and deep-compares the two results — it does not merely ask "did it throw?":
node <skill>/scripts/verify-portable.mjs path/to/mod.mjs#exportName '[args]'
Both invocations receive the same trailing WorkerEnv the worker protocol injects (see
references/api.md), so a documented (n, env) => … signature compares correctly instead of
reporting a capture that doesn't exist.
The result carries a reason:
-
captured — a real ReferenceError; the leaked identifier is named in the output.
-
diverged — no error, but the two sides computed different answers. This is the case a
throw-only check would miss entirely: typeof MULT !== "undefined" ? n * MULT : n doesn't
throw in the worker, it just silently takes the other branch and returns the wrong number.
-
vacuous — both sides returned undefined. A comparison of two "nothing"s proves nothing.
This is the common shape for the skill's headline use case — in-place mutation of pixel/matrix
buffers, transferables, SharedArrayBuffer — where a capture can silently corrupt the mutation
itself while the return value stays undefined on both sides. Never reported as a pass. Give
the function something checkable to return, or use --no-compare if you knowingly accept that
the comparison cannot help you here.
-
threw — the candidate raised its own error (not a leaked identifier); that's the candidate's
business, not a boundary problem.
-
A function that branches on env.runtime (e.g. (n, env) => env.runtime === "node" ? n * 2 : n) will also report diverged: the main-thread stub uses runtime: "main" (deliberately, so a function that decides based on which side it's on makes a real, observable choice) which is neither "node" nor "web". This is a false alarm if the branch is intentional and portable — use --no-compare for these.
Fails with captured or diverged → fix the identified capture, pass it as an argument, re-run.
Fails with vacuous → the gate could not verify anything either way; make the function return a
checkable value, or accept the risk explicitly with --no-compare.
Consequences, stated honestly:
- The candidate runs twice (once per side). A function with side effects — writes a file,
increments an external counter, sends a request, mutates a shared object — pays for both runs.
Know what you're passing in before running this on anything but a pure function.
- A non-deterministic function (
Math.random(), Date.now()) will legitimately compute two
different results and get reported as diverged even though it's perfectly portable. That's a
false alarm, not a bug in the candidate. Use --no-compare for these — it drops back to the
"did it throw?" check and the candidate runs once, in the worker only.
--no-compare is honest about what it gives up. It skips the comparison entirely — no
vacuous, no diverged, no captured-via-divergence. A --no-compare pass means only "it ran
without throwing", nothing about whether the worker computed the right answer. Reach for it
deliberately (non-deterministic candidates, or a nothing-returning candidate you accept the risk
on), not as a way to make a vacuous or diverged failure go away.
- Residual limitation: the gate only exercises the code path your sample args take. A capture
on a branch the args never reach can still slip through. Sample args must exercise the path
under test.
Gate 2 — improvement. Re-measure the Phase 2 metric with the same command and report both
numbers, before and after:
BEFORE worst frame 133 ms / jank 98.9%
AFTER worst frame 18 ms / jank 0%
If the number did not move, say so and revert. "It compiles", "no errors", and "tests pass"
are not evidence: no test suite notices jank. A no-op offload is never reported as a win.
API
See references/api.md for the full surface and the browser/Node differences.