Internals: Workers
How a Worker behaves underneath: who owns the thread, what actually crosses between two agents, and why an open port does not hold the process open.
For signatures see the Web APIs reference. The interface is the HTML dedicated worker — not part of the WinterTC Minimum Common API, so this is a deliberate step into the HTML Standard, in the direction Deno and Bun took.
The options
| Option | Description | |
|---|---|---|
type | standard | "module" only; "classic" throws |
name | standard | the worker's self.name |
permissions | ours | "inherit", or capability names to grant, bounded by the spawning agent's own set |
env | ours | "inherit" or an object of variables |
memory | ours | heap ceiling in megabytes, bounded by the spawning agent's own |
credentials | — | unsupported: it governs how a classic script is fetched over HTTP |
Three of the six are non-standard, and necessarily so. HTML has no notion of a capability; a worker that could read the whole environment because its parent could would make deny-by-default stop at the first new Worker; and a browser has no per-agent memory budget to divide, because it is not the process. Deno reaches the same conclusion on capabilities and spells it deno: { permissions } — still behind --unstable-worker-options; here it is part of the constructor.
All three narrow and none widen. A worker is granted a subset of what its parent holds, given values its parent could already read, and held to a ceiling no higher than its parent's — otherwise anything holding workers would step over each of them by doing the work in a worker.
One thread, one isolate, and a provider between
The runtime spawns nothing. Starting an agent is an injected capability like every other reach outside the isolate:
new Worker(url) └▶ worker_spawn op ── capability: workers └▶ WorkerHost provider └▶ OS thread ──▶ its own V8 isolate ──▶ its own Driver
V8Engine is !Send by V8's threading model, so a worker's runtime is built on the thread that will drive it — the host is handed a factory rather than a runtime. Deno's WebWorker is constructed the same way, for the same reason.
That seam is what a scheduler-backed host replaces: agents could be green tasks on one thread, or live in other processes, and nothing above the provider changes.
The object graph never crosses the op boundary
An op handler receives a marshaled Value — a closed enum. A Map, a cycle or a class instance arriving that way would be flattened to its String(value) coercion, so postMessage(anything) cannot be an op that takes the message.
Instead the graph is serialized on the JS side, before the op:
worker.postMessage(msg, [buf]) └▶ __structuredSerialize(msg) engine builtin, V8 ValueSerializer └▶ __ops.worker_post(id, bytes) the op moves a byte array, as every op does └▶ WorkerHost::post ──▶ channel ──▶ worker thread └▶ __structuredDeserialize ──▶ MessageEvent
So the op contract is untouched, and Value never grew a variant. Deno's op_message_port_post_message has the same shape.
The bytes are V8's serialization format, which is engine-specific and versioned. They are valid only between isolates of the same build — never a wire format, never something to persist.
What a worker may do
A worker starts with no capabilities and is granted them explicitly, bounded above by its parent's set:
new Worker(url, { permissions: ["net"] }) new Worker(url, { permissions: "inherit" }) // everything the parent holds
Omitting it is not "inherit" — unlike env, where omitting and "inherit" mean the same thing. Passing data is not granting authority: a parent can only hand over values it could already read, whereas a capability it did not name is one it did not mean to give. An unknown name throws rather than being skipped, since a dropped typo leaves the worker on the degraded path with the denial surfacing three layers away.
No chain of spawns widens the original grant. This is stricter than Deno, which clones the parent's permissions unmodified — under esrun, where the parent usually holds everything, a worker holds nothing until you say so.
Spawning itself needs two grants, not one: workers, and imports — the parent has to read the worker's entry module, and reading a module is what imports grants. So --deny-all --allow-workers alone is refused, and the refusal names the flag to add. Node requires --allow-fs-read alongside --allow-worker for the same reason; Deno requires --allow-read.
Its own static imports still load. Otherwise deny-by-default would mean single-file workers. The static graph is loaded and linked under the parent's authority — the parent named the module and could already read it — and the capability set narrows to the worker's own before evaluation begins. That is safe for one specific reason: instantiation runs no guest code. Nothing the worker's author wrote executes under the wider set.
Dynamic import() is not the same operation, and needs imports granted at the spawn. A static graph is literal specifiers in source the parent already read; import() computes its specifier while the worker runs — from a message, from input — so it reads and executes a file chosen at runtime, on the worker's own authority. Gating it is what makes "starts with nothing" mean anything, and the refusal names the spawn rather than --allow-imports, which would grant it to the wrong agent.
Nesting works, bounded by the same chain rather than by hiding the constructor. Every level re-applies the rule against its own set: a grandchild asking for net gets nothing if its parent has none, however much the agent driving the process holds.
The environment is attenuated, not inherited. permissions narrows authority; env narrows data:
new Worker(url, { env: { DATABASE_URL: unmask(env.DATABASE_URL) } })
A worker handed an environment needs no env permission to read it, because nothing was granted — the parent could already read every value it passed on. That is the same move permissions makes, one level down, and it is the only way to say "this variable and no other": --allow-env=<names> is set by the deployment, not at the spawn. A handed environment wins over the host's, and secret-looking names are re-masked on arrival, so a Secret stays one.
Node's SHARE_ENV has no equivalent, deliberately. A shared mutable environment is an undeclared side channel between agents; postMessage is the declared one. For the same reason a parent's own env.X = … is invisible to its workers: each agent's env is seeded from the host snapshot, not from the parent's object.
Process control is not delegated. onSignal is refused inside a worker: a signal is delivered to the process, and watching one suppresses the default action, so a worker taking SIGTERM would be deciding — from a thread the program may not know is running — whether the process declines to die. exit() inside a worker ends that worker without setting the process's exit code.
Referencing
A live worker is a reason for the process to keep running — right up until a pool holds four idle ones waiting for the next job, at which point it is the reason the process never exits. unref() gives up the claim without ending anything:
const w = new Worker(url); w.unref(); // still running, still delivering; no longer a reason to stay up w.ref(); // back to keeping the process alive
The claim is a count the agent holds, not the pending receive. The receive cannot be taken back — an idle worker's is already in flight, so flipping its keep-alive would only take effect on the next message, and for an idle worker there is no next message. The count is asked afresh every time the loop wonders whether to stop.
Node and Bun both have unref(); Deno has neither.
What keeps the process alive
| Still running | Keeps the process alive? |
|---|---|
| A live worker | Yes — as in Node and Deno |
An open MessagePort | No |
An open BroadcastChannel | No |
The two "no" rows are deliberate and narrow. A port's receive pump is an outstanding async op, so left counted it would make new MessageChannel() with an onmessage — ordinary code — never exit. Those receives are marked unref: still polled, still resolving, but not a reason for the loop to stay open.
The justification is that a port whose peer is in this agent can only receive if this agent runs code, so waiting while the loop is idle is provably futile. A port transferred to a worker is held open by that worker instead, via the row above.
terminate() interrupts the isolate, so it stops a worker spinning in a synchronous loop or parked in Atomics.wait.
Backpressure
postMessage never refuses a message. HTML does not permit it to fail for queue depth, and Node, Deno and Bun all queue without limit — so a producer that outruns its worker grows memory, and the only thing that can stop it is the producer.
for (const job of jobs) { w.postMessage(job); if (w.queued > 1000) await drain(); // your choice, not the runtime's }
queued is the number posted and not yet taken; self.queued inside a worker is the mirror, for results the parent has not taken yet. Advisory, like a socket's bufferedAmount. No other runtime exposes either.
The send itself is synchronous all the way down — a queue push has nothing to wait for, which is also what MessagePort always did. It was briefly an async op here, and that was worse than redundant: every send held one of the agent's async-op slots, so a burst of about 1150 exhausted them and made every async op in the agent throw.
Transfers
| Type | On transfer |
|---|---|
ArrayBuffer | Contents travel in the message; sender detaches |
SharedArrayBuffer | Not transferred — the allocation is shared, in both agents at once |
MessagePort | The id moves; anything already queued for it stays queued |
| Streams | Original locks; chunks flow across a port pair, with backpressure |
SharedArrayBuffer is the one case where memory is genuinely shared, which is what makes Atomics between agents mean anything. Atomics.wait blocks inside a worker and throws a TypeError on the agent driving the loop — the ECMAScript agent record's [[CanBlock]], and the split HTML makes between window and worker agents. Without that, one call would park the only thread that can make progress and hang the process.
A transferred stream is not copied: it is piped across a channel as chunks are produced, so an endless stream is transferable. Backpressure crosses with it — the reading agent asks for each chunk and the writing agent's write() waits — so a fast producer cannot run away into the port's queue.
Costs
Message round trip, release build, from bench/worker-postmessage.js:
| Payload | Per message | Throughput |
|---|---|---|
| 1 KiB | 0.065 ms | 15 MiB/s |
| 64 KiB | 0.131 ms | 476 MiB/s |
| 1 MiB | 0.512 ms | 1952 MiB/s |
| 8 MiB | 4.342 ms | 1843 MiB/s |
The 1 KiB row is fixed overhead — two op crossings, a promise, a tick, a thread wake-up. Messages are not free; a worker earns its keep on work, not chatter.
The serialized payload is copied once on the JS→Rust crossing. At 1 KiB that is roughly 0.2% of the cost and at 8 MiB roughly 10%. Removing it would mean giving every provider signature that carries bytes a container able to own a V8 backing store, which is not a trade worth making at these numbers.
Failure
An uncaught exception or unhandled rejection fires error on the parent's Worker the tick it happens, and ends the worker. Both halves are the contract: a worker holding a receive pump open never finishes on its own, so waiting for it to end would mean the report arrived long after anything could act on it — and a failure that escaped every handler its author wrote leaves the agent in a state nobody can vouch for, so a supervisor gets one clean transition to restart on rather than an agent left in the rotation.
The event carries the failure in pieces — message alone, filename/lineno/ colno, and an error rebuilt as the class it was thrown as:
w.onerror = (e) => { e.error instanceof RangeError // true, with the worker's own .stack e.error.name // "RangeError" e.filename, e.lineno, e.colno // where };
e.error is necessarily a new object: the failure crossed a thread. Standard classes are restored; anything else becomes an Error carrying the right name, which is the discriminator that survives regardless — a DOMException is told apart by "AbortError", not by its constructor.
A worker takes responsibility for its own failure by calling preventDefault() in its own error or unhandledrejection listener; a claimed failure is neither reported nor fatal. And a worker that merely hears about a child's failure has not failed itself, so an unclaimed error on a Worker goes to the console rather than escalating — without that, one leaf failure would take down every ancestor without an onerror.
Limits
Each worker's isolate carries its own heap ceiling: its parent's, or lower if the spawn named one. Reaching it ends that worker and no other, with e.error.name === "ERR_WORKER_OUT_OF_MEMORY" naming the limit. Node is the only other runtime with a per-worker ceiling (resourceLimits.maxOldGenerationSizeMb); in Deno and Bun a runaway job takes the whole process with it.
The ceiling every agent starts from is --max-heap=<mb>, and by default it is sized from the machine — the container's memory limit when there is one, else the host's memory.
The reference host also caps concurrently live workers (64 by default) — a worker costs a thread and an isolate, so an unbounded new Worker() loop is a way to exhaust both.
A blob: URL minted on one agent does not resolve on another: the object-URL store is per-isolate, where the spec scopes it to the agent cluster. A worker's URL must name a file, so data: and blob: are refused there too. Neither is planned — both schemes carry code and data around a page, and here the file is already on disk and the bytes already cross by postMessage.