skip to content

structuredClone() runs synchronously on the calling thread. What does that cost on a large object graph, and how does it show up when profiling a page?

level: seniorimportance: nice to knowfreq 28%

answer

  1. no promise, no yielding
  2. the thread is stopped while it walks
  3. paid on both sides of a message
  4. shape beats size for cost
  5. a million objects, not a million bytes

basics

~20 s

The clone walks and rebuilds the whole graph before returning, blocking the thread for the duration — so a large structure produces a long task that delays rendering and input handling. Cost scales with the number of distinct nodes and bytes, not with the value's nesting depth.

solid answer

~60 s

There is no asynchrony hiding inside it: `structuredClone` serializes and deserializes the entire reachable graph before the next line runs, so the main thread is blocked for that whole time and cannot paint a frame or run an input handler. In a profile it appears as one uninterrupted block of scripting inside the calling function — a long task with no clear culprit in your own code, because the work is inside the platform call. The same cost is paid at a `postMessage` boundary, and paid twice: the sender serializes on its thread and the receiver deserializes on its own before the `message` event fires. Cost tracks the number of distinct objects and the bytes they hold — a million small objects is expensive, one large `ArrayBuffer` is a comparatively cheap byte copy. The fixes are structural: send less, batch or chunk, prefer compact binary representations over deep object trees, and remember that moving work to a worker does not help if you pay the clone to get the data there.

code

javascript · 10 lines
javascript
const big = Array.from({ length: 200000 }, (_, i) => ({ i, s: 'x'.repeat(20) }));

const t0 = performance.now();
structuredClone(big);
console.log(`object graph: ${(performance.now() - t0).toFixed(1)} ms blocked`);

const bytes = new Uint8Array(200000 * 24);
const t1 = performance.now();
structuredClone(bytes);
console.log(`typed array: ${(performance.now() - t1).toFixed(1)} ms blocked`);

go deeper

for a junior

Know that structuredClone returns a value immediately rather than a promise, which means the thread is busy for the whole copy — so copying a very large structure is not free.

for a middle

Explain what drives the cost: the number of distinct objects and the bytes they hold, not depth. Note that a message boundary runs the algorithm twice, once on each thread.

for a senior

Diagnose it from a profile as a contiguous scripting block at the call site, and prescribe structural fixes — send less, chunk, change the data shape — rather than micro-tuning. Call out the worker round trip that pays two clones to avoid one calculation.

for a principal

Own the boundary budget: decide what may cross a thread or storage boundary and in what representation, so payload growth is a reviewed decision instead of a gradual regression nobody attributes to the clone.

## What "synchronous" costs you `structuredClone(value)` returns a value, not a promise. Between the call and the return, the algorithm walks every reachable object, produces a serialized form, then rebuilds a fresh graph from it. The thread that called it does nothing else in the meantime — on the main thread that means no rendering, no event handlers, no timers. ```js const big = Array.from({ length: 200_000 }, (_, i) => ({ i, s: 'x'.repeat(20) })); const t0 = performance.now(); structuredClone(big); performance.now() - t0; // tens of milliseconds, all of it blocking ``` There is no incremental or yielding mode. If the graph is big enough to matter, the only question is where and how often you pay. ## What drives the cost - **Number of distinct objects.** Each one is visited, recorded in the memory map, and reconstructed. Allocation on the deserialize side is usually the dominant term. - **Total bytes.** Strings and buffers are copied. - **Not depth, and not the number of references.** Because each object is serialized exactly once regardless of how many properties point at it, a heavily shared graph costs less than its expanded tree form. - **Shape matters more than size in bytes.** Ten megabytes as a single `ArrayBuffer` is essentially a memory copy. Ten megabytes spread across a million small objects is a million allocations plus property work, and is far slower. ## The postMessage multiplier At a worker or cross-document boundary the algorithm runs twice: the sender serializes synchronously inside the `postMessage` call, and the receiver deserializes synchronously before the `message` event handler is entered. The receiving cost is invisible in the sender's profile and easy to misattribute — the handler looks slow when the deserialize before it was the expensive part. This is what makes the naive "move it to a worker" reflex backfire: if you post a huge object graph in and get a huge one back, the main thread pays two clone costs it was not paying before, and may end up worse off than when it did the work inline. ## Reading it in a profile In a performance recording it shows as a single contiguous scripting block attributed to the calling frame, with no children of your own. Symptoms that point at it: a long task that appears right at a state-snapshot or message-send site; an input response that stalls whenever a large structure is copied; a frame drop whose timing lines up with a `postMessage` rather than with rendering work. If the browser attributes long tasks, the entry sits on the calling script. ## What to do about it 1. **Send less.** Post the fields the receiver actually needs, or a diff of what changed, instead of an entire store snapshot. This is usually the largest win and costs nothing architecturally. 2. **Change the shape.** A columnar or binary representation — parallel typed arrays instead of an array of objects — turns per-object work into a bulk byte copy. Encoding and decoding is extra code, but the clone cost collapses. 3. **Chunk.** Split one enormous message into several smaller ones so each clone is a short task and the thread can interleave rendering between them. 4. **Don't clone what you can hold once.** Avoid defensive deep copies of large structures in hot paths; copy the small part you intend to mutate. 5. **Zero-copy handoff exists for buffers.** When the payload is an `ArrayBuffer` and the sender no longer needs it, the platform offers a transfer path that avoids the copy entirely — a mechanism worth knowing about in its own right. ## The judgment to show The interesting answer is not "cloning is slow". It is that the *shape* of the data determines the cost, and that the boundary tax is easy to overlook because half of it is paid on the other side. Measure before restructuring: on a few thousand small objects the clone is microseconds and any redesign is premature. The discipline is to know where the large graphs are and to keep them off the boundary.

  • Why can moving work to a worker sometimes make main-thread responsiveness worse?
    Because the data has to get there. The main thread pays a synchronous serialize on the way in and a synchronous deserialize on the way back, and both are long tasks. If the computation is small relative to the payload, you have added two blocking clones to avoid one cheap calculation.
  • Why is one large ArrayBuffer cheaper to clone than an array of a million small objects of the same total size?
    A buffer clone is essentially a bulk byte copy with one allocation. An array of objects requires visiting each object, recording it in the memory map, allocating a replacement, and copying its properties one at a time — per-object overhead that dwarfs the raw byte cost.
  • How would you confirm that a clone is the cause of a long task rather than guessing?
    Record a performance profile and look for a contiguous scripting block attributed to the calling frame with no children of your own. Cross-check by bracketing the call with `performance.now()`, or by temporarily posting a trimmed payload and seeing whether the block shrinks proportionally.

saying these in an interview costs you the question

  • Assumes structuredClone is async or yields to the event loop
  • Thinks only the sender pays the cost at a postMessage boundary
  • Believes any work moved to a worker is automatically a win
  • Says cost depends on nesting depth rather than node count
  • Optimizes the clone before measuring that it is the bottleneck

context