I Wanted spawn() for JavaScript Threads
I want server-side JavaScript threads to have an API like this:
const a = 20;
const b = 22;
const result = await spawn(() => a + b);
// 42The function runs on another core. It captures a and b from the surrounding scope. await gives me 42 when the work finishes. There is no worker entry file, no onmessage handler, and no small message protocol that grows around the actual work.
Whenever I had CPU-heavy work in a server, I wished this API existed. Web Workers and Node's worker_threads already provide the threads. The part I wanted was the closure. A function should be able to take its local context with it when it moves to another thread.
The closure problem
JavaScript gives me no way to ask a function for the values it captured. fn.toString() returns the source, but it loses the environment:
const userRequest = { password: "..." };
const saltRounds = 12;
const fn = () => bcrypt.hash(userRequest.password, saltRounds);The source contains userRequest and saltRounds. The values live somewhere inside the closure, where a library can't reach them. A worker can receive the function source or a set of values, but the language doesn't provide a way to ask for both.
I started experimenting with the part I could inspect. spawn() can find its call site in the stack, read that source file, and parse it with the TypeScript compiler API. An AST walk can identify free variables, which are the names used by the function but defined outside it.
That gives me the names, but still not the values. The useful question became: what code can run at the call site, where those values are visible?
The eval bridge
The answer is eval():
import * as bcrypt from "bcrypt";
const userRequest = {
username: "admin",
password: "correct_horse_battery_staple",
};
const saltRounds = 12;
const hash = await eval(spawn(async () => {
return await bcrypt.hash(userRequest.password, saltRounds);
}));spawn() analyzes the function and returns a generated expression. In simplified form, it looks like this:
globalThis.__worker_wrapper__(
{ userRequest, saltRounds, bcrypt },
["bcrypt"],
"async () => bcrypt.hash(userRequest.password, saltRounds)",
"file:///app/server.ts",
);The important part is the object literal. spawn() cannot evaluate { userRequest, saltRounds } from inside its own function body. The direct eval() at the call site can. JavaScript resolves those names in the lexical scope containing the eval call, and the values become ordinary properties ready to send to the worker.
That is the whole bridge. eval() doesn't create a thread. The runtime's Worker implementation does that. eval() gives generated code one chance to read the caller's local variables, which is the piece the normal worker API leaves out.
The worker receives the function source and structured-cloned values, then runs them in a separate V8 isolate. A mutation to a captured object stays in the worker. Imported modules are evaluated in the worker as well, so a module such as bcrypt doesn't need to be cloned as data.
The idea is deliberately narrow. The generated string is trusted code that comes from the program itself. Passing request data into eval() turns this scope bridge into a code injection bug, so user input never belongs in it.
Making it feel like a thread
Once the closure crossed the boundary, the API followed the shape I wanted from the beginning:
const handle = eval(spawn(async (signal?: AbortSignal) => {
while (!signal!.aborted) await doWork();
return "stopped";
}));
handle.cancel();
console.log(await handle);The result is a JoinHandle. It is thenable, so await handle joins the worker. cancel() sets a shared cancellation flag and fires the closure's AbortSignal. abort() terminates the worker when cooperative cancellation isn't enough.
Generator functions use the same handle to stream progress:
const handle = eval(spawn(async function* () {
for (let i = 0; i < 10; i++) yield await step(i);
return "complete";
}));
for await (const progress of handle) render(progress);
console.log(await handle); // "complete"Structured concurrency fits the same model:
{
await using _scope = scope();
eval(spawn(() => work(1)));
eval(spawn(() => work(2)));
} // both workers have finished hereThe scope keeps a list of handles created inside it. When the scope closes, it waits for all those workers. A failure still lets the other workers finish before it reaches the caller. par(items, fn) uses the same closure capture to split a parallel map across a worker pool.
The extra eval() is an ugly mark on the API, but it stays at the one place where work crosses from the caller into a worker. The function itself remains an ordinary function, and the result remains something I can await.
Shared memory is the second problem
Cloning gives workers isolation. It also means that a module-level mutex in the main thread and a module-level mutex in a worker are two unrelated mutexes. Each isolate evaluates the module independently.
I needed an explicit shared object for coordination:
const jobs = new Shared(new Channel<string>(4096));
const producer = eval(spawn(async () => {
await jobs.value.send("job-1");
await jobs.value.send("job-2");
jobs.value.close();
}));
for await (const job of jobs.value) console.log(job);
await producer;Channel uses a SharedArrayBuffer ring buffer and Atomics, so the parent and worker operate on the same bytes. Shared<T> gives the object an identity based on its source file, line, and column. When the worker evaluates the copied source, it finds the same identity and hydrates the same backing buffer.
That identity trick also gives me mutexes, semaphores, wait groups, and condition variables across isolates. A mutex guard uses Symbol.dispose, so the normal path makes release hard to forget:
{
using guard = await sharedLock.value.lock();
update(guard.value);
}The memory model still differs from Go or Rust. Ordinary JavaScript objects remain cloned snapshots. Shared state requires a SharedArrayBuffer and explicit synchronization. The API has a systems-language shape, while the engine underneath still has Web Worker isolation.
What the trick costs
Source inspection makes the inline API possible, and it also creates a clear set of limits. The source file has to exist at runtime, and the call site has to retain the shape the AST analyzer expects. Bundling and generated code need care. A separate worker entry file avoids those assumptions.
Captured values also have to cross an isolate boundary. Functions and symbols can't be structured-cloned. Channel messages use JSON, so they don't accept values such as BigInt, typed arrays, or cyclic objects. An ordinary object remains a copy unless it is backed by shared memory.
The interesting part of this project is the small gap between the API I wanted and the primitives JavaScript already has. Workers provide parallel execution. Structured cloning provides isolation. SharedArrayBuffer and Atomics provide coordination. AST analysis finds the names, and one direct eval() connects those names to the values in the caller.
The full source for this experiment is in experimental-threads.