The runtime implements HTML's messaging primitives — MessagePort,
MessageChannel, BroadcastChannel and MessageEvent — and exposes them both
as globals and through a node:worker_threads module.
const channel = new MessageChannel();
channel.port1.onmessage = (event) => console.log(event.data);
channel.port2.postMessage({ hello: "world" });
const worker = new Worker("./worker.js");
worker.postMessage({ port: channel.port2 }, [channel.port2]);MessagePort, MessageChannel, BroadcastChannel and MessageEvent are
lazy globals: the name is placed on the first read of it, so an app that
never mentions one never pays for it. They are ordinary globals once read —
instanceof, subclassing and property access all behave normally.
require("node:worker_threads") (or import of the same specifier) returns a
frozen module. Its channel half is not a re-implementation: the classes it
exports are the very objects the globals of those names hold, so
require("node:worker_threads").MessagePort === globalThis.MessagePort.
MessagePort has no constructor — new MessagePort() throws a TypeError.
Ports come from a MessageChannel or arrive on a message.
"Real" means genuine behaviour, and for a class the same object the global of
that name holds. "Shim" means a bridge over the runtime's own Worker, which
has no thread pool, no stdio plumbing and no per-thread environment. "Throws"
means deliberately unsupported.
| export | status | notes |
|---|---|---|
MessagePort |
real | The global MessagePort. |
MessageChannel |
real | The global MessageChannel. |
BroadcastChannel |
real | The global BroadcastChannel. The process-wide registry described below. |
receiveMessageOnPort(port) |
real | Synchronously pops one queued message, { message } or undefined. Works on a port that was never started; a close sentinel at the head closes the port and reports undefined. |
isMainThread |
real | false inside a runtime worker. |
threadId |
real | 0 on the main isolate, the worker's id (from 1) inside one. |
isInternalThread |
real | Always false; this runtime has no internal threads. |
markAsUntransferable(obj) |
real | Brands obj so listing it in a transfer list is a DataCloneError. |
isMarkedAsUntransferable(obj) |
real | Reads that brand. |
markAsUncloneable(obj) |
real | Brands obj so serializing it at all is a DataCloneError, in structuredClone and every postMessage alike. |
setEnvironmentData(key, value) |
real, deviates | Clones and stores process-wide. No per-thread snapshot — see below. Passing undefined (or omitting the value) deletes the key. |
getEnvironmentData(key) |
real, deviates | Deserializes a fresh copy per read, on any isolate. |
resourceLimits |
shim | Always {}; the runtime imposes no per-worker limits and reports none. |
SHARE_ENV |
shim | Exported so the spelling resolves, but inert — see below. |
threadName |
shim | Always undefined. |
workerData |
shim | Always null — see below. |
parentPort |
shim | null on the main isolate. Inside a worker, a MessagePort-shaped EventTarget over the worker's existing parent channel: postMessage forwards to the global postMessage, message/messageerror are re-dispatched from the worker global scope, start() and close() are no-ops. It is not a real port: not transferable, no queue of its own. |
Worker |
shim | A class over the runtime's global Worker with a small Node-style emitter (on/once/off/removeListener) for message, messageerror, error, online and exit. postMessage(value, transfer) and terminate() forward. online is emitted off a microtask after construction, not from the thread. exit (always code 0) fires exactly once, when the thread has ended, whether the worker was terminated or ended by its own close(); terminate() resolves at the same point. Unsupported options throw a TypeError naming the option: workerData, env, eval, transferList, and stdin/stdout/stderr when explicitly truthy. |
postMessageToThread |
throws | Error: postMessageToThread is not supported in this runtime. |
moveMessagePortToContext |
throws | Error: moveMessagePortToContext is not supported in this runtime. |
locks |
absent | Web Locks are not implemented; the property does not exist. |
Node copies the environment-data store into a worker when it is spawned, so a
later write on the parent is invisible to it. Here the store is one
process-global map, and a worker reads it live: a setEnvironmentData call
made after a worker started is visible to that worker.
Values are cloned on the way in and deserialized fresh on each read, so mutating the object you passed does not reach a reader, and two readers never share one object.
exit is emitted once, from the runtime's end-of-worker notification, so every
message and error the worker produced before it ended has been delivered
first. Node reports the thread's exit code; this runtime has none to report, so
the code is 0 whichever way the worker ended — terminate(), its own
close(), an uncaught error, a missing entry or its heap limit. terminate()
resolves with 0 at the same moment exit fires. A parent that is itself
tearing down never delivers the notification, so a terminate() awaited from a
dying isolate stays pending, as it does in Node when the parent process exits.
An error the worker scope leaves unhandled reaches the parent as a real
ErrorEvent dispatched on the Worker, so worker.onerror and
addEventListener("error", …) both fire, interleaved in the order they were
installed. Two things differ from a browser:
- Only primitives cross the isolate boundary, so
event.erroris alwaysnull; the worker's stack comes through asevent.stackTrace, a string alongside the standardmessage,filenameandlineno. - Inside the worker,
onerroris still a direct call taking the thrown value — not anErrorEvent, and not reachable throughaddEventListener. Returning truthy from it handles the error and stops it from reaching the parent, which is the same "handled" contractworker.onerrorhas on the parent side (a truthy return there cancels the event, as doespreventDefault()from any listener).
globalThis is not itself an EventTarget here. It forwards
addEventListener, removeEventListener and dispatchEvent to an internal
EventTarget that backs the worker global scope, and native delivery
dispatches on that internal target — which is what keeps app code from
intercepting message delivery by replacing globalThis.dispatchEvent. The
consequence is visible on the event: event.target inside a worker's message
handler is that internal target, not globalThis.
It is exported so that an options.env === SHARE_ENV spelling resolves rather
than being a ReferenceError. There is one process environment and it is never
copied, so nothing distinguishes sharing it from not. (env is a rejected
Worker option regardless.)
There is no channel that would carry it: the Worker constructor rejects the
workerData option outright, so the export is permanently null. Send an
opening postMessage instead.
"Same user agent", in the spec's terms, is the app process. Every
BroadcastChannel built with the same name joins one group regardless of which
isolate constructed it, so a worker and the main isolate reach each other by
name alone. A channel is receiving from the moment it is constructed and stays
strongly held until close().
The GC model is Node's, not the browser's: a port is held strongly by the runtime from creation until it is closed. An unreferenced-but-unclosed port does not go away, and neither does its channel, its queue, or anything the queue's messages hold. Close the ports you are done with.
const { port1, port2 } = new MessageChannel();
port1.onmessage = handle;
// ... later
port1.close();Closing behaves as one channel-wide event:
close()sends acloseevent — a plainEvent, not aMessageEvent— to the port being closed and to its sibling. A channel with one end left is no channel, so both ends learn about it. (A namedBroadcastChannelgroup is different: members join and leave it freely, so only the leaving member gets the event.)- The
closeevent reaches a port that was never started. Enabling is about messages; a port whose sibling died always learns about it. - On the port being closed the event fires synchronously, inside
close(). On the sibling it orders behind whatever was already queued to it, so messages already sent are still delivered first. postMessageon a closed port is a silent no-op. It still serializes: the transfer list's side effects and its errors do not depend on delivery, so a bad transfer list throws and a good one detaches its buffers, and only then is the message dropped.port.close(callback)registerscallbackas a one-shotcloselistener before closing.
Delivery follows HTML's port-enable rules rather than starting automatically:
- A port starts delivering on its first
messagelistener — eitheraddEventListener("message", …)or anonmessageattribute assignment. The firstonmessagewrite counts even when it isonmessage = null: it is the assignment, not the handler, that claims the listener slot. - It stops when the last
messagelistener goes away, and messages queue again until one returns. port.start()enables delivery for code that only usesaddEventListenerand wants control over when the queue drains. As in Node, it does not pin the port on: removing the lastmessagelistener stops delivery again until a listener returns orstart()is called once more.receiveMessageOnPort(port)bypasses all of it and pops one message synchronously.
BroadcastChannel has no enable step; it receives from construction.
A transfer list moves ownership instead of copying. It is the second argument
to port.postMessage / worker.postMessage, and options.transfer for
structuredClone.
| value | in a transfer list | in the message graph |
|---|---|---|
ArrayBuffer |
transferable — the receiver gets the original backing store, the sender's buffer is detached (byteLength 0, every view over it zero-length) |
cloned |
MessagePort |
transferable — the sender's port is closed as a handle while its queue and channel membership travel to the receiver, so a sender on the far end keeps queueing into it while it is in flight | DataCloneError unless it is also listed |
SharedArrayBuffer |
not transferable — DataCloneError |
shared: the receiver builds a second SharedArrayBuffer over the same memory, and writes through either are visible through the other |
| everything else | DataCloneError |
per the structured clone rules |
Every one of these is a DOMException named DataCloneError, so both
e.name === "DataCloneError" and instanceof DOMException detect them.
| condition | message |
|---|---|
| the port doing the posting is in its own transfer list | Transfer list contains source port |
| a listed port is already detached (closed, or transferred away) | MessagePort in transfer list is already detached |
| the same port listed twice | Transfer list contains duplicate MessagePort |
the same ArrayBuffer listed twice |
The transfer list contains the same ArrayBuffer twice |
a listed ArrayBuffer is detached or not detachable |
An ArrayBuffer in the transfer list is detached and cannot be transferred |
a listed value branded by markAsUntransferable |
Cannot transfer object of unsupported type. |
| anything else in the list (a non-object included) | Found invalid value in transferList. |
| a port reachable in the message but not listed | Object that needs transfer was found in message but not listed in transferList |
a value branded by markAsUncloneable, anywhere in the graph |
Cannot clone object of unsupported type. |
The duplicate-port message ends in the constructor name of the listed object,
so a subclass of MessagePort names itself there.
Those are the checks the native collector runs. What reaches it depends on the
entry point, and a list argument of the wrong shape is a TypeError rather
than a DataCloneError:
port.postMessage(value, transfer)does the WebIDL sequence conversion in JavaScript, so an array, any iterable, or a{ transfer }dictionary all work. Anything else isTypeError: postMessage: transfer is not iterable.worker.postMessage(value, transfer)is native all the way down and takes an actual array; omitting it or passingundefined/nullmeans "transfer nothing", and any other value isTypeError: The transfer list must be an array.structuredClone(value, { transfer })accepts any iterable and screens each entry in its own wrapper first, so an untransferable entry there is still aDataCloneErrorbut carries that wrapper's message,structuredClone: value in transfer list is not transferable, rather thanFound invalid value in transferList.
Validation and serialization run to completion before a single buffer is
detached or a single port is handed over. A DataCloneError from the middle of
a graph therefore leaves every port and every buffer in the list exactly as
it found them — still open, still holding their memory — so a failed
postMessage can be corrected and retried.
The listed ports and buffers are re-checked after the write as well, because
writing the graph runs user getters and one of them may have closed a listed
port or detached a listed buffer; those late failures are the same
MessagePort in transfer list is already detached and An ArrayBuffer in the transfer list is detached and cannot be transferred. What they undo is the
transfer — nothing is detached, nothing changes hands — not what the getters
did on the way there: a port a getter closed stays closed.
A listed port that the value itself never names still travels, but on arrival
it has no way out: a message event hands it over in event.ports, while
structuredClone and receiveMessageOnPort return only the value. Those two
close such a port as soon as it arrives, so its sibling learns the channel is
gone instead of queueing into a port nothing can ever read.
The runtime's own Worker and the worker global scope are EventTargets that
deliver real MessageEvents, so worker.onmessage, worker.addEventListener,
and the same pair on globalThis inside a worker, all work and interleave in
installation order. Handlers keep receiving the payload as event.data.
worker.postMessage differs from port.postMessage in one respect: an
interop/native object anywhere in the graph is delivered as an empty object
rather than raising a DataCloneError, which is long-standing behaviour app
code relies on. Transfer is not part of that leniency — a port in a worker
transfer list is validated exactly as it is everywhere else, since degrading a
transfer would strand the port's sibling.
A Worker is held strongly by the runtime from the moment its thread starts
until that thread ends, the way a browser keeps a running worker's handle
alive. Dropping every reference to one does not stop it: it keeps running, and
it keeps dispatching message and error events at the handlers installed on
it.
(function () {
const worker = new Worker("./worker.js");
worker.onmessage = handle; // still fires; nothing here holds `worker`
worker.postMessage("go");
})();Being a GC root also means a Worker is a well-behaved key: put one in a
WeakMap, WeakSet or WeakRef and the entry survives for as long as the
worker runs.
The root is released when the worker ends — terminate(), or the worker's own
close(). From then on the object is collectable like any other, and the
runtime drops the native side with it. Nothing about a finished worker is
kept alive.
When the worker's thread has finished, the runtime dispatches a plain Event
named nsworkerended on the Worker object. It is internal and
non-standard — the web has no end-of-worker event, and the name is deliberately
outside the standard namespace. It exists so that node:worker_threads can
report 'exit' for a worker that ended by its own close(); app code should
not rely on it. The event is best effort: a worker whose parent is already
tearing down never delivers it, because the parent's own teardown disposes the
worker anyway.