WorkersGENERALENGINE-SPECIFIC

Structured Clone and Transferables

Cloning copies — cost proportional to size, paid synchronously on both threads. Transferring moves ownership — near-free, and the sender loses access entirely.

The intent, the obvious build, and why it breaks

Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.

The question

When I send data to a worker, is it copied or moved, and what does each choice cost me?

The user intent

A person drops a 60 MB CSV onto the page and expects parsing to start immediately, without the drag animation stuttering as the file is handed off.

The obvious build

Read the file into an object, postMessage it to the worker, and let the worker do the parsing. The heavy work is off the main thread, so the page stays smooth.

Why it breaks

The page stutters at exactly the moment of hand-off. postMessage serialises synchronously on the calling thread, so a large payload is a long task on the main thread before the worker has even seen it (Long Tasks).

How it breaks in a real browser
  • The page stutters at exactly the moment of hand-off. postMessage serialises synchronously on the calling thread, so a large payload is a long task on the main thread before the worker has even seen it (Long Tasks).
  • It stutters again on the way back, because the result is deserialised on the main thread the same way.
  • Peak memory roughly doubles: the original graph on one side and a full copy on the other, both live until one is collected.
  • The message throws DataCloneError because the object graph contains a function, a class method, a DOM node or a Proxy. Structured clone represents data, not behaviour.
  • The class instance arrives as a plain object. Prototypes do not survive: new Money(5) goes out and { amount: 5 } comes back, and every method on it is gone.
  • Getters are evaluated and their results copied. A lazily-computed property becomes an eagerly-computed one, and a getter with a side effect fires at serialisation time.
IntentEventStateUI LogicDOM WorkNetworkLayout / PaintPixelsFeedback

What is actually happening

In the browser, not in the framework.

  • The structured clone algorithm walks the value and produces an independent copy in the receiving realm. It handles far more than JSON: Map, Set, Date, RegExp, ArrayBuffer, every typed array, Blob, File, FileList, ImageData, Error, BigInt, and — unlike JSON — cyclic references, preserved as cycles.
  • It refuses functions, symbols, DOM nodes, Proxy objects and anything whose behaviour cannot be represented, throwing DataCloneError. It also drops prototypes: the copy is a plain object of the same shape, not an instance of the same class.
  • Cost is proportional to the size and the node count of the graph, and it is synchronous on the thread that calls `postMessage` — the receiver then pays deserialisation on its own thread. Two blocking costs per hop.
  • Transferables are a different mechanism. Pass an object in the second argument's transfer list and its underlying resource is moved rather than copied: the receiver gets it, and the sender's reference is detached.
  • A detached ArrayBuffer has byteLength === 0; a typed array over it reads as empty and writing to it silently does nothing. Posting it again throws. The sender genuinely no longer has the data.
  • The transferable list is short and specific: ArrayBuffer, MessagePort, ImageBitmap, OffscreenCanvas, and the stream types (ReadableStream, WritableStream, TransformStream). Everything else is cloned even if you list it.
  • A SharedArrayBuffer is neither: it is not copied and not detached — both realms address the same memory. That is a third model with its own requirements (Shared Memory and Cross-Origin Isolation).
  • structuredClone(value) is the same algorithm exposed as a plain function on both threads — a deep copy for a value you never intend to send anywhere.

What this makes the browser do

And which of it is avoidable.

  • Walking the object graph, allocating the serialised form, and tracking already-visited objects so cycles terminate and shared references stay shared.
  • Allocating an entire second graph in the receiving realm's heap during deserialisation.
  • Garbage collecting both, which for a large payload means a collection pause on top of the copy itself (Memory Leaks).
  • For a transfer: updating ownership of the backing store and detaching the sender's view. There is no copy of the bytes — this is a pointer hand-off, and it is why the cost is essentially independent of size.
  • For a Blob or File: the reference is cloned, not the contents. Blobs are backed by storage the browser already manages, which is why sending one is cheap even when it is large.

Copy or move: the same hop, two mechanisms

The two calls below differ by one argument and by everything else. The first copies the bytes and leaves the sender with its own working data. The second hands over the backing store and leaves the sender holding a detached husk.

The because here is the whole lesson, so read the assertion in the second block carefully: after the transfer, bytes.length is zero. That is not a bug to work around — it is the mechanism working, and any code that still expects to read those bytes is the thing that has to change.

Sending a large Float64Array
Clone
const bytes = new Float64Array(8_000_000)   // ~64 MB
fillFromCsv(bytes)

worker.postMessage({ id, bytes })
// serialise on THIS thread, synchronously, ~64 MB
// then deserialise on the worker thread, ~64 MB again
// peak memory: two live copies
// main thread: one long task, right when the user let go of the file
console.log(bytes.length)  // 8_000_000 — sender still owns it
Transfer
const bytes = new Float64Array(8_000_000)   // ~64 MB
fillFromCsv(bytes)

worker.postMessage({ id, bytes }, [bytes.buffer])
//                                  ^ the transfer list
// no copy: the backing store changes owner
// cost is ~independent of size
console.log(bytes.length)  // 0 — DETACHED. sender no longer has it.
// every other view over bytes.buffer, anywhere, is now empty too

Cloning is O(size) and blocks the sender before the worker has seen anything; transferring is a pointer hand-off whose cost barely moves with size. You pay for it in ownership: the sender's views are detached instantly, and any code still holding one reads zeroes with no error to tell you why.

What survives the crossing

Structured clone is much more capable than JSON.stringify and much less capable than "just send the object". The distinction it draws is between data and behaviour: state crosses, behaviour does not, and identity crosses only in the sense that two references to one object stay two references to one copy.

The cycle row is worth noticing because it is the clearest case where clone beats JSON outright. The Proxy and class rows are the ones that produce production bugs, because both fail late rather than loudly.

ValueClone?Transfer?What actually happens
ArrayBuffer / typed arraysYesYesCloning copies every byte; transferring moves the backing store and detaches the sender's views.
MessagePortNoYesMust be transferred — this is how a private channel is handed to a worker.
ImageBitmap / OffscreenCanvasYesYesTransfer these. Cloning an ImageBitmap copies decoded pixel data, which is exactly what you were trying to avoid.
ReadableStream / WritableStreamNoYesTransferring a stream lets the worker consume a response body the main thread never buffers.
Map, Set, Date, RegExpYesNoReconstructed as the same type in the receiving realm — a real advantage over JSON.
Cyclic object graphsYesNoCycles and shared references are preserved as cycles and shared references. JSON.stringify throws on these.
Blob / File / FileListYesNoThe reference is cloned, not the contents. Cheap regardless of file size — usually the best way to hand a large file to a worker.
Class instancesPartlyNoOwn enumerable properties survive; the prototype does not. You get a plain object with the same keys and no methods.
Getters / accessorsPartlyNoEvaluated during serialisation and the *result* is copied. Side effects fire on the posting thread.
Functions, symbols, Proxy, DOM nodesNoNoDataCloneError, thrown synchronously from postMessage. Behaviour cannot be represented.
SharedArrayBufferNeitherNeitherShared: both realms address the same memory, no copy and no detach — and only where the page is cross-origin isolated (Shared Memory and Cross-Origin Isolation).

Where the time goes

SIMULATEDProduced by an Engineer Atlas model to show relative shape, not measured. The ratio between hand-off and computation depends on payload size, engine and device; what transfers between contexts is that clone cost scales with size while transfer cost does not.

The shape below is what makes the choice concrete. In the cloning row the main thread is busy twice — once serialising the input and once deserialising the result — with the worker idle in between waiting for data it has not been given yet.

In the transferring row those blocks collapse and the worker starts almost immediately. Nothing about the computation changed; the difference is entirely in the hand-off. These are relative units illustrating the shape of the two strategies, not measurements.

Same 64 MB payload, cloned versus transferredrelative units — shape only, not a measurement
CLONE · main: serialise input
CLONE · worker: deserialise
CLONE · worker: compute
CLONE · main: deserialise result
CLONE · main: render
TRANSFER · main: hand off
TRANSFER · worker: compute
TRANSFER · main: take back result
TRANSFER · main: render
  • CLONE · main: serialise inputSynchronous. No frames, no input handling, no accessibility updates during this block.
  • CLONE · worker: deserialisePaid again, on the other thread. The worker has still not started the actual work.
  • CLONE · main: deserialise resultA second main-thread stall, arriving just as the user expects the UI to update.
  • TRANSFER · main: hand offA pointer moves. Size barely matters.
  • TRANSFER · worker: computeStarts roughly fifteen units earlier, and the main thread is free the entire time.

The computation is identical in both rows. Everything that changed is the hand-off — which is why "the worker made it faster" is usually the wrong description of what happened.

How to build it

Most important first.

  • Move bytes, not objects. A typed array over an ArrayBuffer transfers in constant time; the same numbers as an array of objects clone in time proportional to their count (List Virtualization shows the same principle applied to the DOM).
  • Better still, do not send the data at all: have the worker fetch it, or read it from IndexedDB itself. Data that never crosses the boundary costs nothing to cross it (IndexedDB).
  • Send a Blob or File reference rather than its contents. File clones cheaply and the worker can call .arrayBuffer() on its own thread.
  • Design the wire format deliberately: flat typed arrays plus a small header beat a rich nested graph, for exactly the reason a columnar layout beats a row of objects (Parallelism Can Destroy Locality in Concurrency).
  • Where you need both sides to keep a copy, slice() before transferring so the detach costs you a buffer you meant to lose.
  • Never transfer a buffer you still hold a reference to elsewhere. Every view over a detached buffer becomes empty at the same instant, including ones in code you did not write.
  • For images, createImageBitmap() then transfer the bitmap. Decoding happens off the main thread and the bitmap moves rather than copies (Images and Fonts).

Keyboard, focus, semantics, announcement

A required field on every lesson in this domain, not a section added when there is room.

  • The direct a11y consequence is that a large clone is a long main-thread task, and during a long task the accessibility tree does not update, focus does not move, and live-region announcements queue. Serialising 60 MB on the main thread is as damaging to a screen-reader user as running the parse there would have been (The Accessibility Tree).
  • Transferring instead of cloning removes that stall entirely, which makes it an accessibility fix, not only a performance one — the thread stays available to service assistive-technology queries.
  • Because a transfer detaches the sender's copy, any UI that re-reads that data to re-announce or re-render must instead keep the derived summary it needs *before* transferring. Losing the buffer must not mean losing the accessible name or description you built from it.
  • Progress during a long hand-off still has to be announced by the page. The worker can post "parsed 20 of 60 MB", but only the main thread can put that text in a polite live region (Live Regions and Announcement).
  • If a clone is unavoidably slow, mark the affected region aria-busy="true" before it starts and clear it afterwards, so assistive technology is told to wait rather than reading a half-updated view.

What can go wrong

Failure modes
  • DataCloneError on a value that contains a function, a class with methods, a DOM node or a Proxy — often deep inside a payload assembled from several sources.
  • Silent prototype loss: the message succeeds, the object looks right in the console, and calling a method on it throws several frames later.
  • A detached buffer read as empty. The classic version is transferring a buffer and then rendering a chart from the typed array you still have a reference to — which now has length zero, so the chart is blank with no error at all.
  • Peak memory spikes on large clones causing the tab to be discarded on a memory-constrained device.
  • The mitigation failing: you switch to transferables and now the main thread cannot re-render from its own data, because it gave it away. Transfer is not a free upgrade; it changes ownership semantics.
  • A SharedArrayBuffer posted on a page that is not cross-origin isolated, which throws rather than degrading to a copy (Shared Memory and Cross-Origin Isolation).
What can arrive out of order
  • A transfer detaches the sender's buffer immediately and synchronously, but any pending asynchronous work that intended to read it — a queued requestAnimationFrame callback, an in-flight decode — will find it empty when it runs.
  • Two messages transferring views over the same buffer: the first detaches it, and the second throws. Order matters and it is not obvious from the call sites.
  • A result transferred back can arrive after the state that requested it has been replaced. Correlate by request id before writing anything into the DOM (Talking to a Worker).
Security
  • Cloning is a copy across realms of the same origin. It grants no privilege and crosses no trust boundary — a cloned value is exactly as trustworthy as its source.
  • A transfer is an ownership change with a real consequence: after it, the sender cannot audit, re-check or redact what it sent. Validate before you transfer, because afterwards you have nothing left to validate.
  • Getters run during serialisation. An object assembled from untrusted input with a getter on it executes that getter on the posting thread, which is a small but genuine reason to post plain data rather than rich objects.
  • Cloning does not sanitise. A string that was an XSS payload before the hop is the same payload after it; escaping happens at the DOM sink, on the main thread (Sanitization and Trusted HTML).
  • Blobs and Files carry user data by reference. Sending one to a worker that uploads it is a data-flow decision worth being explicit about (Storage Security and Durability).
Misreads
  • "postMessage is async so it does not block." Delivery is async; serialisation is not. The sender pays it synchronously, on its own thread, before anything is queued.
  • "Structured clone is JSON with more types." It preserves cycles and shared references, handles binary data, and refuses functions — behaviourally it is a different algorithm, not a superset of JSON.stringify.
  • "Transferring is just a faster clone." It is not a copy at all. The sender loses the data, and code elsewhere holding a view over that buffer silently reads zeroes.
  • "My class survived the trip because the properties are there." The prototype did not. It is a plain object wearing the same property names.
  • "Transfer everything to be safe." Transfer what the sender genuinely no longer needs. For small payloads the clone is cheaper than the reasoning about ownership.
  • "A SharedArrayBuffer is a kind of transferable." It is a third category — not copied, not detached, shared — and it requires cross-origin isolation to exist at all.

Measuring it, and what changes in the field

How you would see this
  • In the Performance panel, a clone appears as a solid synchronous block on the posting thread immediately before the message. Its width *is* the payload size (A Mental Model of the Devtools).
  • Compare the same operation with and without a transfer list: the block should collapse to near nothing. That A/B is the cleanest possible demonstration of the mechanism.
  • The Memory panel shows the doubled peak of a large clone as two live graphs; a transfer shows one (Debugging Memory).
  • Check buffer.byteLength === 0 after posting to confirm a transfer actually happened. Listing a non-transferable object in transfer is silently ignored, and the clone happens anyway.
  • Time the round trip in relative terms across payload sizes. Clone scales with size; transfer stays flat. That difference in *shape* is the thing to verify, not any particular number.
Slow device, slow network, large data, old tab
  • On a slow device the clone block is proportionally longer, so the payload size at which cloning becomes visible is much lower than on a development machine (The Real Cost of JavaScript).
  • With a large dataset, cloning can dominate the operation completely — the worker finishes its computation faster than the main thread finished handing over the input.
  • On a memory-constrained device, the doubled peak of a large clone is the difference between working and having the tab discarded.
  • With many small messages, per-message fixed overhead dominates and payload size barely matters; batching helps far more than transferring (Talking to a Worker).
  • On a slow network, fetching inside the worker means the bytes never touch the main thread at all — the strongest version of this optimisation is not choosing between clone and transfer, but avoiding the question.
What this costs
  • Transfer costs you the data. If the main thread needs it too, you must copy deliberately — and then you have paid for a clone anyway, just at a moment you chose.
  • A byte-oriented wire format is faster and less readable. Debugging a Float64Array with a header offset is meaningfully harder than logging an array of objects (A Method for Frontend Bugs).
  • Cloning is convenient and forgiving. For small payloads it is absolutely the right default, and optimising it is a waste of the reader's attention.
  • Fetching inside the worker removes the hop but duplicates request logic — auth headers, retries, error mapping — on a second thread (Retries, and the Duplicate Order).

Where this applies

Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.

  • GENERALThe structured clone algorithm and the transfer semantics — detaching the sender's ArrayBuffer, refusing functions with DataCloneError, preserving cycles — are specified in HTML and behave identically across Blink, Gecko and WebKit.
  • ENGINE-SPECIFICOptimisations around it are not specified: engines may avoid a physical copy for large buffers within the same process, and the size at which cloning becomes perceptible differs substantially between V8, SpiderMonkey and JavaScriptCore. Measure on the engine and device you care about rather than porting a threshold.

Where the depth lives

This domain teaches the browser-side mechanism and hands the rest off.

Domains that do not exist yet
  • Programming Languages & Runtime Internals — structured clone is a serialisation format an engine implements natively, and the reason prototypes cannot cross is that a prototype is a reference into a realm the receiver has no access to.