Skip to main content

Workers

Worker and SharedWorker are globals in every script. A dedicated worker (Worker) is an isolate of the script's own, started by the constructor and ended with the script. A shared worker (SharedWorker) is an isolate several scripts reach by name: the host starts it on the first connection and stops it when the last one goes away. Both follow the web's Worker and SharedWorker, and both talk over message ports, so the surface is what a browser script already knows: postMessage, onmessage, onconnect, MessageChannel.

As on the web, a worker is an isolated JavaScript environment. It has its own event loop and shares no memory with its opener. Here it is also an isolate the daemon hosts, so it shows up in yeet ps under its own id. Everything that crosses between a worker and its opener is a message, copied by structured clone or handed over by transfer.

// worker.js — a dedicated worker. `postMessage` and `onmessage` are globals here.
onmessage = (e) => postMessage(`pong:${e.data}`);
// main.js — yeet run -T ./main.js
const worker = new Worker('./worker.js');
worker.onmessage = (e) => {
console.log(e.data); // pong:ping
worker.terminate();
};
worker.postMessage('ping');

A worker script can also be inline. There is no Blob and no URL.createObjectURL, so a data: URL is how a payload built or fetched at runtime becomes a worker:

const worker = new Worker('data:text/javascript,onmessage = (e) => postMessage(e.data * 2)');

Globals and modules​

Worker and SharedWorker are on the global of any script yeet run starts. yeet:worker exports the same two constructors for code that prefers an explicit import. MessageChannel and the other port types are not globals; they come from yeet:messaging.

import { Worker, SharedWorker } from 'yeet:worker';
import { MessageChannel, MessagePort, MessageEvent } from 'yeet:messaging';
ExportModuleDescription
Workeryeet:workerA dedicated worker: one isolate for this script alone
SharedWorkeryeet:workerA connection to the shared worker that serves (scope, script, name)
MessageChannelyeet:messagingA pair of entangled ports
MessagePortyeet:messagingOne end of a connection: postMessage, start, close, onmessage
MessageEventyeet:messagingThe event a port fires: data and ports

Inside a worker, the global is the ordinary yeet global with the worker's connection surface laid on top; see DedicatedWorkerGlobalScope and SharedWorkerGlobalScope. What a browser worker script would reach for and will not find here:

Web globalHere
self, name, location, navigatorAbsent. Use globalThis. There is no name option on Worker.
importScriptsAbsent. A worker script is an ES module; use import.
MessageChannel, MessagePort, MessageEventNot globals; import from yeet:messaging.
Event, EventTarget, ErrorEventNot globals; import from yeet:events.
structuredCloneAbsent. Values are cloned when posted, not on demand.
Blob, URL.createObjectURLAbsent. Inline a worker with a data: URL.
BroadcastChannelAbsent. A MessageChannel is the way between two workers.
SharedArrayBuffer, AtomicsPresent as V8 built-ins, but a SharedArrayBuffer cannot be posted (could not be cloned). Isolates share nothing.
fetch, WebSocket, cryptoAbsent in every isolate; see what is not available.

And the yeet surface a worker does without:

In a scriptIn a worker
ttyAbsent. A worker has no terminal; console output goes to its own stream, read with yeet attach -c.
yeet:tuiFails to resolve, since it needs a terminal. The layout and text modules (yeet:tui:core, yeet:tui:layout, yeet:tui:text, yeet:tui:face) load.
yeet.args{ _: [] }. Pass configuration in a message.
SharedWorkerAbsent; see Shared workers.
yeet.exit()Ends the worker alone.

yeet, console, timers, import.meta, and every other yeet:* module are as in any script.

Concepts​

The identity and lifetime rules are the web's. MDN's pages on using web workers, DedicatedWorkerGlobalScope, SharedWorkerGlobalScope, and the channel messaging API apply; what follows is where yeet is specific, and a summary of the differences is at the end.

Dedicated workers​

new Worker(spec) starts an isolate that runs the script spec names and belongs to this script alone. Every construction starts a new one. Nothing else can reach it, and it ends with the script that opened it.

Inside, the one connection back to the opener is on the global: postMessage(value) sends, onmessage receives, close() ends the connection. postMessage is live from the script's first line, and messages the opener posted before the script finished evaluating are delivered once it has. A worker can open dedicated workers of its own; a chain of them ends with the script at its root.

Shared workers​

new SharedWorker(spec, name?) connects to the shared worker that serves spec under name, starting one if none is running. The result is a connection, not the worker: worker.port is a MessagePort, and everything the script says goes through it.

Inside a shared worker there is no global postMessage, because there is no single peer. Each connection arrives as a connect event on the global, with its port in e.ports[0]:

// registry.js — a shared worker holding state for every connection
let count = 0;
onconnect = (e) => {
const port = e.ports[0];
port.onmessage = (m) => {
if (m.data === 'inc') count += 1;
port.postMessage(count);
};
};

A shared worker is identified by the scope of the opening script, the script as resolved (two specifiers for one file name one worker), and the name (omitted is the empty name; 'a' and 'b' are two workers). Connections that agree on all three reach the same isolate.

A worker, dedicated or shared, cannot open a shared worker: SharedWorker is not on its global, and constructing the one yeet:worker exports throws A worker cannot open a shared worker. The web exposes SharedWorker on Window alone, and the rule also closes a loop in which two workers hold each other alive. A shared worker can still delegate to dedicated workers of its own; see Delegate from a shared worker.

Scope: who can share a worker​

The web keys a shared worker by origin. Here the key is the scope of the isolate that opens it:

  • A service is a scope. Every isolate unit of one service, and every per-connection isolate a route of that service spawns, is in that service's scope. A SharedWorker one unit opens is the same worker for every other unit; see Share a worker across a service.
  • A plain yeet run is a scope of its own. Two yeet run commands never share a worker, even for the same script and name. Within one run, every new SharedWorker of the same key reaches one worker.
  • A dedicated worker takes its opener's scope.

Lifetime​

WorkerStartsEnds
DedicatedOn new Worker(spec)On terminate() from the opener, close() inside it, or when its opener's isolate ends
SharedOn the first new SharedWorker for its (scope, script, name)When its last connection goes away

A connection goes away when either end closes it or the isolate holding it dies. Nothing collects a port that was merely dropped: a client that never calls port.close() holds its shared worker for as long as the client runs, and holds its own run open too. A timer a worker set, or a dedicated worker it opened, does not hold it: when nothing it serves is left, the worker ends and those go with it.

Workers in yeet ps​

A worker is an isolate, so yeet ps lists it on a row of its own, with an id you can yeet kill and a console you can yeet attach -c. Its name is worker:<scope>:<script>: the scope is the service's id for a worker a service unit opened, or the opener's isolate id for a plain yeet run, and the script is the absolute path the specifier resolved to (a data: URL shows the URL).

$ yeet ps
ID STATUS UPTIME EVAL HEAP REDIRECTS NAME
11862 running 23.7s 23ms 866.9 KiB - ./producer.js
11863 running 23.7s 24ms 924.9 KiB - worker:ordinary_opposite:/srv/counter/registry.js
11864 running 23.7s 17ms 866.7 KiB - ./consumer.js
11879 running 775ms 7ms 276.2 KiB - ./client.js
11880 running 763ms 9ms 276.2 KiB - worker:11879:/srv/counter/registry.js

Here ordinary_opposite is a service id (the ID column of yeet service list) whose two units share row 11863, and row 11880 is the same script opened by the plain run on row 11879, which got its own worker because it is in its own scope.

  • yeet kill <worker id> ends the worker. Every connected port fires close. Nothing restarts it: a dedicated worker is gone for good (its opener keeps running), and a shared worker comes back, with fresh state and a new id, the next time something in its scope opens it.
  • yeet kill <opener id> ends the dedicated workers the opener started and gives up its connections to shared workers, which end if that was their last one.
  • yeet attach -c <worker id> tails the worker's console. A worker has no terminal, so yeet attach without -c fails with Daemon returned error: JS thread gone.
  • yeet service stop ends a service's units and every worker they held. restart is a stop and a start, so a shared worker's state does not survive it. yeet service tree does not list workers; yeet ps does.

Errors​

A worker reports its own errors first, on its own global, the way a web worker does: an uncaught exception becomes an error event there, an unhandled rejection an unhandledrejection event. onerror takes the web's five arguments, (message, filename, lineno, colno, error), and handles the error by returning true; a listener added with addEventListener('error', …) gets an ErrorEvent and handles it with preventDefault(). onunhandledrejection gets a PromiseRejectionEvent with reason and promise.

What an unhandled error does:

In the workerThe workerThe other side hears
Uncaught exception in a handlerEnds.Dedicated: an error event on the Worker, with message, filename, lineno, colno, and a null error. Shared: only close on every connected port.
Script throws while loadingEnds.Dedicated: an error event on the Worker. Shared: an error event on the SharedWorker, then close on its port; every client that connects hears the same, since each open starts the worker again. filename is empty and lineno is 0 for a load-time error.
Script cannot be found, compiled, or linkedEnds.As a load error, with the message naming the module. The constructor does not throw for these.
Unhandled promise rejectionKeeps running.Nothing.

An error event on a Worker or SharedWorker stops there: cancelling it changes nothing, and an uncancelled one is not raised again in the opener as it would be on the web. The opener's exit status is its own.

A failed worker holds nothing open. A script that opens a worker, does nothing else, and has no timer ends as soon as the first event about the failure is delivered, so the close that follows an error is heard only when something else keeps the run alive.

A worker's console is its own stream. Nothing it logs, and no failure inside it, reaches the opener's terminal; tail it with yeet attach -c <worker id>. The daemon's log records each uncaught exception with the isolate id.

The constructor does throw for a data: URL whose media type is not JavaScript, for a type other than "yeet" (a TypeError), and for a specifier that cannot be made a string.


O Worker​

new Worker(spec: string, options?: { type?: 'yeet' })

Starts a dedicated worker running the script spec names. spec is resolved like an import, relative to the script that names it, and may be a data:text/javascript,… URL. The only accepted type is "yeet", the default; "classic" and "module" throw a TypeError. There is no name option and no credentials. The constructor requires new. MDN: Worker.

A handler assigned later in the same turn still receives the first message, and a message posted before the worker has loaded is queued until it has.

M worker.postMessage​

worker.postMessage(value: any, transfer?: (MessagePort | ArrayBuffer)[]): void

Sends value to the worker by structured clone, handing over what transfer lists. Throws for a value that cannot be cloned; returns silently once the worker has been terminated.

M worker.terminate​

worker.terminate(): void

Ends the worker. Calling it twice is harmless. The worker gets no event; it simply stops.

Events on a Worker​

EventHandlerWhen
messageworker.onmessage, addEventListener('message', …)The worker called postMessage. A MessageEvent.
errorworker.onerror, addEventListener('error', …)The worker's script failed to load or threw an unhandled exception. An ErrorEvent with message, filename, lineno, colno, and error: null. See Errors.

A Worker fires no event when the worker ends, whether by close() inside it, yeet kill, or yeet.exit(). A postMessage after that is dropped. To hear a worker go, talk to it over a MessageChannel port, which does fire close.


O SharedWorker​

new SharedWorker(spec: string, name?: string)
new SharedWorker(spec: string, options?: { name?: string, type?: 'yeet' })

Opens a connection to the shared worker for (scope, spec, name), starting the worker if none serves that key. spec is resolved like an import, relative to the script that names it. A second argument that is not an object is coerced to a string and used as the name, as on the web (42 names the worker "42"). type must be "yeet" if given. MDN: SharedWorker.

Throws inside any worker. Never throws for a missing or broken script: that is reported later as an error event followed by close on the port.

P sharedWorker.port​

sharedWorker.port: MessagePort

This script's end of the connection, an ordinary MessagePort: assign onmessage or call start() before messages are delivered, postMessage to send, close() to give the connection up. Every new SharedWorker is a new connection with a port of its own, even to a worker this script already reached. A port that is never closed holds the worker, and this script's run, open; a port whose worker failed to load holds nothing.

Events on a SharedWorker​

EventHandlerWhen
errorsharedWorker.onerror, addEventListener('error', …)The worker's script failed to load. An ErrorEvent as on a Worker.

Messages arrive on sharedWorker.port, not on the SharedWorker. A runtime error inside the worker reaches no client; ports just close if the worker ends.


O DedicatedWorkerGlobalScope​

The global of a dedicated worker's script: the yeet global, plus the one connection back to the opener. The name is the web's; there is no constructor by that name here, and no self. MDN: DedicatedWorkerGlobalScope.

F postMessage​

postMessage(value: any, transfer?: (MessagePort | ArrayBuffer)[]): void

Sends to the opener. Live from the script's first line.

F close​

close(): void

Ends the connection, and with it this worker. Anything posted after is dropped.

P onmessage​

onmessage: (event: MessageEvent) => void

Receives from the opener; addEventListener('message', …) works too. Messages the opener posted before the script evaluated are delivered once it has.

P onerror, onunhandledrejection​

The worker's own error reports; see Errors. Return true from onerror to handle an exception here rather than in the opener.

P Worker​

Present: a worker can open dedicated workers. SharedWorker is absent; the one yeet:worker exports can be imported but throws when constructed.


O SharedWorkerGlobalScope​

The global of a shared worker's script. There is no postMessage and no close on it, because a shared worker has no single peer: it talks on the ports it is handed. MDN: SharedWorkerGlobalScope.

P onconnect​

onconnect: (event: MessageEvent) => void

A client connected; event.ports[0] is the worker's end of that connection, a MessagePort. Assign its onmessage (or call start()) to receive, postMessage to answer, close() to drop that one client. Connections that arrive before a listener exists are queued and dispatched once one does, including after a top-level await. addEventListener('connect', …) works too.

P onerror, onunhandledrejection​

The worker's own error reports; see Errors. No client hears an error the worker did not handle.

P Worker​

Present. SharedWorker is absent. yeet.exit() ends the worker and closes every client's port.


Examples​

Each example is a complete set of files. Run the first file with yeet run -T <file> from the directory that holds them; the output shown is what a run prints. The examples cover what is specific to yeet; for the shape of ordinary worker code, MDN's examples apply unchanged.

The worker goes with its last connection​

Closing the only port ends a shared worker, and the next open starts a fresh one. A script that wants the state kept holds a port open for as long as it wants the worker.

// registry.js — the shared worker
let count = 0;
onconnect = ({ ports: [port] }) => {
port.onmessage = ({ data }) => {
if (data === 'inc') count += 1;
port.postMessage(count);
};
};
// reap.js — yeet run -T ./reap.js
const ask = (port, message) => new Promise((resolve) => {
port.onmessage = (e) => resolve(e.data); // assigning onmessage starts the port
port.postMessage(message);
});
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

const keeper = new SharedWorker('./registry.js');
keeper.port.start(); // a started, open port: this connection counts

const first = new SharedWorker('./registry.js');
console.log('inc ->', await ask(first.port, 'inc'));
first.port.close();
await sleep(50);

const second = new SharedWorker('./registry.js');
console.log('get, keeper still connected ->', await ask(second.port, 'get'));
second.port.close();

keeper.port.close(); // the last connection: the worker ends here
await sleep(50);

const third = new SharedWorker('./registry.js');
console.log('get, after the worker ended ->', await ask(third.port, 'get'));
third.port.close();
inc -> 1
get, keeper still connected -> 1
get, after the worker ended -> 0

Leave out the last close() and the run never exits: an open port holds the worker, and the worker holds the run.

Transfer a buffer, and the message limit​

An ArrayBuffer in the transfer list is handed over: the sender's buffer is detached and the worker owns the bytes. The bytes still travel inside the message, so the size limit applies to a transferred buffer as much as to a cloned one.

// sum.js
onmessage = ({ data }) => {
const bytes = new Uint8Array(data);
let sum = 0;
for (const b of bytes) sum += b;
postMessage({ length: bytes.length, sum });
};
// transfer.js — yeet run -T ./transfer.js
const worker = new Worker('./sum.js');
const buffer = new Uint8Array(1 << 16).fill(3).buffer;
worker.onmessage = ({ data }) => {
console.log('worker saw', data);
worker.terminate();
};
console.log('before:', buffer.byteLength);
worker.postMessage(buffer, [buffer]);
console.log('after: ', buffer.byteLength);
before: 65536
after: 0
worker saw { length: 65536, sum: 196608 }

Without the transfer list the buffer is cloned and the sender keeps its bytes. Make the buffer 1 MiB and either form fails with A message of 1048582 bytes exceeds the 126976 byte limit.

Handle a worker's errors​

A dedicated worker tells its opener about an exception it did not handle, then ends. One it handles on its own global keeps running, and nothing about it reaches the opener's terminal either way.

// fragile.js
onerror = (message) => {
// Return true to handle the error here. Comment this handler out and the
// opener's `error` event fires instead, and this worker ends.
postMessage(`handled here: ${message}`);
return true;
};
onmessage = ({ data }) => {
if (data === 'break') throw new Error('asked to break');
postMessage(`ok: ${data}`);
};
// errors.js — yeet run -T ./errors.js
const worker = new Worker('./fragile.js');
worker.onerror = (event) => console.log(`opener heard: ${event.message} at line ${event.lineno}`);
worker.onmessage = ({ data }) => {
console.log(data);
if (data.startsWith('ok: still')) worker.terminate();
};
worker.postMessage('hello');
worker.postMessage('break');
worker.postMessage('still here?');
ok: hello
handled here: Error: asked to break
ok: still here?

Comment out the worker's onerror and the run prints ok: hello, then opener heard: Error: asked to break at line 9, and nothing for the third message. The run still exits 0.

A script that cannot be loaded is reported the same way, never by the constructor:

// load-failed.js — yeet run -T ./load-failed.js
const missing = new Worker('./no-such-file.js');
missing.onerror = (event) => console.log(`load failed: ${event.message}`);
load failed: TypeError: Cannot resolve module "/home/me/workers/no-such-file.js" from "yeet:_bootstrap:dedicated-worker": Module rule error: Failed to create source string

Delegate from a shared worker​

A shared worker cannot open another shared worker, but it can open dedicated workers of its own and hand their answers back to whichever client asked.

// hasher.js — a dedicated worker the shared worker owns
onmessage = ({ data: { id, text } }) => {
let h = 0;
for (const c of text) h = (h * 31 + c.charCodeAt(0)) >>> 0;
postMessage({ id, hash: h.toString(16) });
};
// front.js — the shared worker
import { SharedWorker } from 'yeet:worker';

const hasher = new Worker('./hasher.js');
const waiting = new Map();
let next = 0;

hasher.onmessage = ({ data: { id, hash } }) => {
waiting.get(id).postMessage(hash);
waiting.delete(id);
};

onconnect = ({ ports: [port] }) => {
port.onmessage = ({ data }) => {
if (data === 'nest') {
try { new SharedWorker('./front.js'); } catch (error) { port.postMessage(error.message); }
return;
}
const id = next++;
waiting.set(id, port);
hasher.postMessage({ id, text: data });
};
};
// delegate.js — yeet run -T ./delegate.js
const { port } = new SharedWorker('./front.js');
const answers = [];
port.onmessage = ({ data }) => {
answers.push(data);
console.log(data);
if (answers.length === 3) port.close();
};
port.postMessage('yeet');
port.postMessage('workers');
port.postMessage('nest');
A worker cannot open a shared worker
3888bb
5ae81cb5

The refusal is answered at once, while each hash takes a round trip through the hasher, so it prints first.

Watch workers with yeet ps​

A detached script that holds a shared worker and a dedicated worker shows how workers are listed, tailed, and stopped.

// ticker.js — a shared worker that logs each tick
let ticks = 0;
setInterval(() => console.log(`tick ${++ticks}`), 1000);
onconnect = ({ ports: [port] }) => {
port.onmessage = () => port.postMessage(ticks);
};
// echo.js — a dedicated worker
onmessage = ({ data }) => postMessage(data);
// holder.js — yeet run -d ./holder.js
const shared = new SharedWorker('./ticker.js');
shared.port.onmessage = ({ data }) => console.log(`ticker at ${data}`);
shared.port.addEventListener('close', () => console.log('ticker closed'));

const echo = new Worker('./echo.js');
echo.onmessage = ({ data }) => console.log(`echo said ${data}`);

setInterval(() => { shared.port.postMessage('?'); echo.postMessage('hi'); }, 2000);
$ yeet run -d ./holder.js
[Spawned Detached Isolate 12200]
$ yeet ps
ID STATUS UPTIME EVAL HEAP REDIRECTS NAME
12200 running 3.1s 9ms 276.2 KiB - ./holder.js
12201 running 3.1s 8ms 276.2 KiB - worker:12200:/home/me/workers/ticker.js
12202 running 3.1s 7ms 276.2 KiB - worker:12200:/home/me/workers/echo.js
$ timeout 3 yeet attach -c 12201
tick 4
tick 5
tick 6
$ yeet kill 12201
$ timeout 3 yeet attach -c 12200
echo said hi
$ yeet kill 12200
$ yeet ps
ID STATUS UPTIME EVAL HEAP REDIRECTS NAME

Killing the shared worker fired close on the holder's port (the holder logged ticker closed then) and left the holder running; killing the holder took the dedicated worker with it. yeet attach -c shows lines from the moment it attaches, which is why ticker closed is not in the second attach.

Share a worker across a service​

Two long-lived units of one service reach one registry, and a per-connection route answers each HTTP request from a short-lived isolate that reads the same registry and exits. The service's scope is what makes the three scripts meet in one worker; see Services for the service file.

// registry.js — the shared worker, as above
let count = 0;
onconnect = ({ ports: [port] }) => {
port.onmessage = ({ data }) => {
if (data === 'inc') count += 1;
port.postMessage(count);
};
};
// producer.js — a unit that counts, and holds the worker open
const { port } = new SharedWorker('./registry.js');
port.onmessage = ({ data }) => console.log(`count is ${data}`);
port.addEventListener('close', () => console.log('registry went away'));
setInterval(() => port.postMessage('inc'), 1000);
// count.js — one isolate per request: ask, print, close, exit
const { port } = new SharedWorker('./registry.js');
port.onmessage = ({ data }) => {
console.log(data);
port.close();
};
port.postMessage('get');
# counter.toml — yeet service import counter.toml --now
alias = "counter"

[units.producer]
isolate = "./producer.js"

[units.count]
isolate = "./count.js"
lazy = true # started per request by the route below

[units.web]
web-server = "http://127.0.0.1:9200"

[units.web.routes."/count"]
target = "count"
portal = "console"
tenancy = "per-connection"
$ yeet service import counter.toml --now
Created service counter
✓ Starting quiet_harbor
● quiet_harbor (counter) running restart=no
├─producer isolate id=12300
├─count isolate (lazy)
└─web web-server http://127.0.0.1:9200
└─GET /count console@count per-connection
$ yeet ps
ID STATUS UPTIME EVAL HEAP REDIRECTS NAME
12300 running 4.2s 9ms 276.2 KiB - ./producer.js
12301 running 4.2s 8ms 276.2 KiB - worker:quiet_harbor:/srv/counter/registry.js
$ curl -s http://127.0.0.1:9200/count; sleep 2; curl -s http://127.0.0.1:9200/count
4
6

The worker's scope, quiet_harbor, is the id yeet service list shows for the service. Each request's isolate opens a connection, reads, closes, and is gone before yeet ps can show it; the worker stays because the producer holds its port. Stop the producer and each request would start a fresh registry, read 0, and end it again.


Ports and channels (yeet:messaging)​

Every conversation with a worker runs over a message port: sharedWorker.port is one, and each connect event hands a shared worker one. yeet:messaging exposes the port itself, and MessageChannel for making a pair of your own. MDN: channel messaging.

import { MessageChannel, MessagePort, MessageEvent } from 'yeet:messaging';

O MessagePort​

One end of a connection. There is no public constructor: ports come from MessageChannel, from sharedWorker.port, and from the ports of a MessageEvent. MDN: MessagePort.

MemberDescription
postMessage(value, transfer?)Send value to the other end by structured clone, handing over the ports and buffers in transfer. Ignored on a closed port, and on one that has been transferred away.
start()Begin delivering messages. Until then, what arrives is queued in order. Assigning onmessage starts the port; addEventListener('message', …) alone does not.
close()Give the connection up. The other end fires close; this end fires nothing. Anything queued is dropped.
onmessage / message eventA MessageEvent per message.
close eventThe other end closed or its isolate ended, including a worker that was killed. A plain Event; the port must be started to hear it.

Delivery is always asynchronous: postMessage returns before the other end runs, even when both ends are in one isolate. Ports within one isolate deliver in a microtask; a message to or from a worker is a task of its own.

O MessageChannel​

new MessageChannel(): { port1: MessagePort, port2: MessagePort }

Two entangled ports: what is posted on one arrives on the other. Post one end to a worker in a transfer list and keep the other, and the two have a private line; post each end to a different worker and they have a direct line that no longer involves the opener. Mail already posted on an end travels with it when the end moves. An open channel that nothing posts on does not keep a run alive. MDN: MessageChannel.

O MessageEvent​

new MessageEvent(type: string, { data?: any, ports?: MessagePort[] })

The event a port, a Worker, or a worker global fires for a message: data is the cloned value (null when nothing was sent) and ports the ports transferred with it, in the order the sender listed them. A shared worker's connect event is a MessageEvent too, with the new connection in ports[0] and data null. MDN: MessageEvent.

Structured clone and transfer​

Values cross by V8's structured clone. Plain objects and arrays, Map, Set, Date, RegExp, BigInt, typed arrays and ArrayBuffer, Error instances (constructor, message, stack, and cause, but no extra properties), circular references, NaN, Infinity, and -0 all arrive intact. A getter is read and copied as a plain value; a class instance arrives as a plain object without its prototype; a top-level undefined arrives as null. Functions, symbols, WeakMap, promises, and proxies cannot be cloned: postMessage throws an Error naming the value, such as function f() {} could not be cloned., and sends nothing.

The transfer list takes MessagePort and ArrayBuffer values and nothing else:

  • An ArrayBuffer in the list moves without a copy. The sender's buffer is detached: its byteLength reads 0 and every view on it is empty. Without the list, the buffer is copied.
  • A MessagePort in the list moves to the receiver, which gets it in the event's ports. The sender's handle is dead afterwards. A port must be in the list to be sent at all; one found inside the value alone is refused with A MessagePort must be transferred, not cloned: pass it in the transfer list. A port cannot be listed twice, cannot transfer itself, and cannot be in the list of a message posted on it.
  • Anything else, a Uint8Array included, is refused: A transfer list takes MessagePorts and ArrayBuffers.

A message between isolates is at most 126976 bytes (124 KiB) once encoded, and a transferred ArrayBuffer counts in full, because its bytes cross with the message. A larger postMessage throws A message of <n> bytes exceeds the 126976 byte limit and sends nothing; split large data into several messages.


Differences from the web​

  • Scope, not origin. A service is a scope; a plain yeet run is a scope of its own. See Scope.
  • The script type is yeet. A worker script is an ES module loaded by the yeet loader, so it can import yeet:* modules and anything an ordinary script can. type: 'module' throws.
  • No WorkerGlobalScope. The worker global is the yeet global plus its connection surface; see Globals and modules for what is missing.
  • Nothing collects a dropped port. A client that does not close() holds its shared worker as long as it runs, and holds its own run open.
  • Timers do not hold a worker. A worker ends when nothing it serves is left, whatever it has scheduled.
  • Errors stop at the Worker object. The web escalates an uncancelled error event to the opener's own error report; here it does not. An unhandled rejection does not end a worker, as on the web, but it does end an entrypoint script.
  • No event when a worker ends. A Worker fires nothing for close(), yeet kill, or yeet.exit() inside the worker; a MessagePort does fire close.
  • No credentials. Nothing fetches; the option is ignored.