Web Platform · JavaScript Runtime
Structured Clone, Transferables, and the Worker Message Boundary
What postMessage copies, what it moves, and where the cost lands: serialization on the sender, deserialization on the receiver, and shared memory behind isolation.
A worker and the window that created it share an address space in most implementations and a heap in none. They cannot share objects, because objects carry identity, prototypes, and mutable state that belong to a realm, and a worker is a different realm with a different global object. Everything that crosses the boundary is therefore either copied by a defined serialization algorithm or moved (shared memory is the exception, covered below), and the choice determines both the safety properties and the cost.
postMessage performs structured serialization on the sender and structured deserialization on the
receiver. The result is a graph that is isomorphic to the original with respect to a fixed type list,
with shared references and cycles preserved inside that graph but with no identity relationship to the
sender’s objects. Mutating the sender’s object afterwards has no effect on the copy, and vice versa; there
is no aliasing across the boundary unless memory is explicitly shared.
What survives the copy
The type list is specified, not inferred. It covers the language’s data types — booleans, numbers, big
ints, strings, and the null/undefined distinction — plus Array, plain Object, Map, Set,
Date, RegExp, ArrayBuffer, typed arrays, DataView, Error and its standard subclasses, and a
set of platform objects including Blob, File, FileList, ImageBitmap, ImageData, and
MessagePort.
Several things do not. Functions cannot be serialized, because a function closes over an environment
that has no meaning in another realm; attempting to send one throws a DataCloneError. DOM nodes cannot
be sent to a worker at all, since the worker has no document. Weak collections are excluded because a
copy would either keep entries alive that the sender expected to be collected or lose them silently.
Symbols are excluded, which also means symbol-keyed properties vanish from copied objects. Prototypes are
not preserved: an instance of a user-defined class arrives as a plain object carrying its own enumerable
properties, because the receiving realm has no binding to the constructor and no way to reconstruct the
internal slots. undefined properties survive, but non-enumerable properties and accessors do not —
accessors are evaluated on the sender and their values copied.
Property descriptors are reduced to data properties, and getters run during serialization, which is an observable side effect. A getter that mutates state or throws changes the outcome of the send. The order in which properties are visited is the ordinary own-property order, so serialization is deterministic for objects whose own keys are stable.
Serialization cost is on both threads
The copy is not free and it is not confined to one side. Serializing traverses the whole reachable graph
from the message root, allocating a buffer; deserializing traverses the buffer and allocates the
corresponding objects in the receiver’s heap. For a message containing a graph of n objects, both
passes are O(n), but the constant is large: each value costs a type tag and either an inline
representation or a reference, and each object costs an allocation on the receiving side.
Two consequences follow. First, a large message blocks the sender’s thread for the duration of serialization, so posting a multi-megabyte object graph from an otherwise idle worker still janks that worker. Second, the receiver pays the allocation cost, and if the receiver is the main thread, that cost lands in a task that also has to render. Sending a large data structure to the main thread is therefore a way to move work, not a way to remove it: the total work is the copy plus whatever the handler does.
Repeatedly posting small messages is cheaper in bytes and more expensive in overhead. Each message is a task on the receiving loop, so a stream of thousands of tiny messages costs thousands of task dispatches, each with the microtask checkpoint that follows it.
Transfer moves the buffer instead of copying it
A MessagePort, ImageBitmap, OffscreenCanvas, or ArrayBuffer can be listed in the message’s
transfer list. A transferred object is not copied: ownership moves to the receiver, and the sender’s
handle becomes unusable in a defined way. For an ArrayBuffer, the sender’s object is detached:
byteLength becomes 0, an existing view reports length 0 and yields undefined for its elements, and
constructing a new view over the buffer throws a TypeError. For a port or bitmap, the old handle is
neutered and further operations on it fail.
const buffer = new ArrayBuffer(64 * 1024 * 1024);
worker.postMessage({ buffer }, [buffer]);
console.log(buffer.byteLength); // 0 — detached by the transfer
// In the worker:
self.onmessage = ({ data: { buffer } }) => {
const view = new Uint8Array(buffer); // full length on the receiving side
};
Transfer is the way to hand ownership of bulk binary data to the other thread without copying it; shared memory, covered below, avoids the copy by giving neither side exclusive ownership. The pattern that uses transfer well is a producer-consumer pair that hands a buffer back and forth, each side filling or reading it and then transferring it onward. The buffer is never duplicated, and the memory high-water mark is one buffer per in-flight unit of work. A pool of a small number of buffers, cycled through transfer, avoids both copying and repeated large allocations.
Detachment is observable and cannot be undone. Code that transfers a buffer and then tries to read it
gets a zero-length view rather than stale data, which is a safer failure than a race, but it is still a
failure. The transfer list is processed before the message is queued, so the sender’s handle is dead as
soon as postMessage returns.
Shared memory requires isolation
SharedArrayBuffer is the one type that breaks the copy model: several threads hold views on the same
memory, and writes by one are visible to the others. Because that visibility is a timing side channel,
browsers enable the constructor only in a cross-origin isolated context, established by serving
Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp on the
document. Without those headers the constructor is absent, and feature detection must check for its
presence rather than assume it.
Shared memory has no automatic synchronization. Concurrent plain reads and writes to the same location
are a data race, and the language gives no guarantee about the result. Atomics supplies the necessary
operations: Atomics.load and Atomics.store for individual locations, add, sub, and, or,
xor, exchange, and compareExchange for read-modify-write, and wait/notify for blocking and
waking. All of them operate on integer typed arrays and, unless specified otherwise, provide sequential
consistency: the operations appear in a single total order that every thread agrees on. The API has no
relaxed variant; Atomics.load and Atomics.store take only the typed array and the index, and every
operation carries the same ordering guarantee.
Atomics.wait blocks the calling thread until notified or until a timeout expires, and it is
deliberately restricted: calling it on the main thread of a window throws a TypeError, because blocking
the thread that must render and dispatch input is not a recoverable state. In a worker it is the
standard way to build a mutex or a condition variable, with Atomics.notify waking one or all waiters.
Lock-free structures on top of these primitives are possible and hard to get right; the practical alternatives are a message-passing design, which confines concurrency to well-defined boundaries, or a single-writer design in which exactly one thread mutates a region and the others read under an atomic flag.
Where the message boundary leaks
Transferring a MessagePort through postMessage lets two workers talk directly, without routing every
message through the window. The port begins delivering messages when a message handler is attached or
when start() is called; attaching a handler to a port that has already buffered messages drains them in
order. A port that is closed with close() delivers no further messages, and a transferred port that is
never received keeps its counterpart open, which is a resource leak rather than an error.
Errors do not cross as exceptions. An uncaught error in a worker fires an error event on the worker
object in the parent, with message, filename, lineno, and colno populated; the worker continues
running unless the error was fatal to its script. An exception thrown inside a message handler in the
worker is reported that way, not as a rejection on the sender’s postMessage call, which returns
immediately and has no result channel. Round-trip request/response protocols therefore need their own
correlation identifiers and their own error envelope, and a dropped response must be handled as a
timeout rather than awaited forever.
References
- HTML Living Standard — Safe passing of structured data — WHATWG
- ECMAScript Language Specification — Atomics — Ecma International
- Transferable objects — MDN Web Docs — MDN Web Docs