js-spawn
v0.1.4
Published
Run a function in a Web Worker with spawn(fn) and get a Promise back.
Downloads
253
Maintainers
Readme
js-spawn
Move any function off the main thread by adding one word.
const result = await spawn(() => heavyWork(data));That runs in a Web Worker. Your UI never freezes.
The problem
Your app does something expensive — sorting 100k rows, parsing a big file, hashing, compressing an image. The browser has one thread for your JavaScript and your UI, so while that work runs, nothing else happens. Clicks queue up. Animations stutter. The tab looks broken.
Web Workers exist to fix this. But using one means a second file, a message protocol, and manually forwarding every variable your code needs:
// worker.ts — a whole separate file
self.onmessage = (e) => {
const { rows, threshold } = e.data;
self.postMessage(rows.filter((r) => r.score > threshold).length);
};// main.ts — plumbing
const worker = new Worker(new URL('./worker.ts', import.meta.url), {
type: 'module',
});
worker.postMessage({ rows, threshold });
const count = await new Promise((resolve) => {
worker.onmessage = (e) => resolve(e.data);
});
worker.terminate();With js-spawn, that is:
const count = await spawn(() => rows.filter((r) => r.score > threshold).length);No second file. No postMessage. rows and threshold come along
automatically. You get a Promise back, so await and try/catch work the way
you already expect.
| | Raw Web Worker | js-spawn |
| ---------------------- | ---------------------- | --------------- |
| Extra file | yes | no |
| Passing your variables | manual, by hand | automatic |
| Getting the result | message listener | await |
| Errors | onerror handler | try / catch |
| Cleaning up the worker | you call terminate() | automatic |
Install
npm i js-spawnAdd the plugin to your Vite config:
// vite.config.ts
import { defineConfig } from 'vite';
import { jsSpawnVitePlugin } from 'js-spawn/plugin';
export default defineConfig({
plugins: [jsSpawnVitePlugin()],
});Restart the dev server. That's the whole setup.
The plugin does the real work: it finds every
spawn()call at build time and turns the function into a real worker module. Without it,spawn()throws and tells you so.
Use it
import { spawn } from 'js-spawn';Crunch numbers without blocking the UI
const numbers = Array.from({ length: 5_000_000 }, (_, i) => i);
const total = await spawn(() => numbers.reduce((a, b) => a + b, 0));Sort a big list
const rows = await fetchRows(); // 100k rows
const sorted = await spawn(() => [...rows].sort((a, b) => a.price - b.price));Parse something large
const csv = await file.text();
const records = await spawn(() =>
csv.split('\n').map((line) => line.split(','))
);Use your npm packages inside the worker
import { format } from 'date-fns';
const dates = ['2024-01-01', '2024-06-15'];
// `format` is imported into the worker for you
const labels = await spawn(() => dates.map((d) => format(new Date(d), 'PPP')));Async works too
const data = await spawn(async () => {
const res = await fetch('/api/report');
return res.json();
});Errors come back as rejections
try {
await spawn(() => JSON.parse(brokenJson));
} catch (err) {
console.error('failed in worker:', err.message);
}What gets captured
Anything your function mentions from the surrounding file comes with it — variables and imports alike. You don't list them, and you don't pass arguments.
import { slugify } from './utils';
const prefix = 'post';
const titles = ['Hello World', 'Second Post'];
const slugs = await spawn(() =>
// prefix, titles, and slugify are all captured
titles.map((t) => `${prefix}-${slugify(t)}`)
);Values are copied into the worker, not shared. Changing one inside the worker does not change it on the main thread:
const state = { count: 0 };
await spawn(() => {
state.count += 1; // a copy
});
console.log(state.count); // still 0Return what you want back out — that's the one channel that flows the other way.
Sharing memory on purpose
If you genuinely need both sides to see the same bytes, use a
SharedArrayBuffer:
const shared = new SharedArrayBuffer(4);
const view = new Int32Array(shared);
view[0] = 1;
await spawn(() => {
Atomics.add(new Int32Array(shared), 0, 41);
});
console.log(view[0]); // 42What can't cross
Workers talk over structured clone, so a few things can't travel.
Fine to capture and return: strings, numbers, booleans, null, undefined,
bigint, arrays, plain objects, Date, Map, Set, ArrayBuffer,
SharedArrayBuffer, typed arrays.
Not allowed:
| Value | When you find out |
| --------------------- | ----------------- |
| functions and classes | at build time |
| symbols | at runtime |
| WeakMap / WeakSet | at runtime |
| DOM nodes, window | at runtime |
| most class instances | at runtime |
Runtime errors name the exact path that failed, like captured.user.avatar, so
you don't have to guess.
Functions are the one that surprises people. A worker is a separate context — there is no way to send it a closure. So this fails:
const double = (n: number) => n * 2;
await spawn(() => [1, 2, 3].map(double)); // ❌ build errorMove the helper inside, and it's fine:
await spawn(() => {
const double = (n: number) => n * 2; // ✅ lives in the worker
return [1, 2, 3].map(double);
});Imported functions are the exception — those are re-imported in the worker
rather than copied, so import { format } from 'date-fns' works normally.
Workers are managed for you
You never create or terminate a worker.
- Identical
spawn()calls share one worker instead of each building their own. - A worker stays warm between calls, so bursts of work don't pay startup cost repeatedly.
- Once it goes idle, it's terminated automatically.
- A worker is never terminated while a task is still running.
Overlapping calls are safe — each await gets its own result back, even if they
finish out of order.
Good to know
Server-side rendering. Workers don't exist on the server, so spawn()
throws there. Call it on the client — inside an effect, an event handler, or
after hydration.
Bundler support. Vite only, for now. Webpack, Rollup, and esbuild are planned.
When not to reach for this. Workers cost a few milliseconds to start and copy their inputs. For work that finishes in under ~10ms, or for anything that touches the DOM, stay on the main thread — this is for the genuinely slow stuff.
License
MIT © Mehran Taslimi
