Measuring Structured Clone Cost with performance.now()

A slow postMessage is never one cost — it is a serialize step, a queue wait and a deserialize step, and only a timestamp on each side of each boundary tells you which one is hurting.

This page narrows postMessage Bottleneck Analysis, part of the Debugging, Profiling & Production Optimization reference, down to a single question: how do you put a defensible millisecond number on the copy that the Structured Clone Algorithm performs on your payload? Get that number and the architectural decision that follows — clone, transfer, or share — stops being a guess.

The Three Intervals Inside One postMessage

Before instrumenting anything, be precise about what sits between the two threads. A single message crosses four boundaries and produces three measurable intervals:

Interval Where it runs Blocks How to time it
Serialize Sender thread, synchronously inside postMessage The sender Timestamps immediately before and after the call
Delivery Neither thread — event loop queue plus receiver wake-up Nobody Receiver’s arrival timestamp minus the sender’s post timestamp, on a shared time scale
Deserialize Receiver thread, before your handler sees the object graph The receiver Bracket the first read of event.data

Almost every wrong conclusion about clone cost comes from collapsing two of these into one. A payload that serializes in 0.3 ms but sits in the queue for 9 ms behind a long task is not a serialization problem — it is a scheduling problem, and switching to transferable objects will not fix it. Conversely a 12 ms serialize step is main-thread blocking time that no amount of queue tuning will recover.

The three measurable intervals inside a single postMessage A two-lane timeline. On the main-thread lane, application code runs, then worker.postMessage runs the serialize step synchronously while the sender is blocked, after which the call returns and the sender is free. Between the lanes a dashed delivery box covers the queue wait and receiver wake-up, which blocks neither thread. On the worker lane the thread is idle until the message is dispatched, then deserialization happens on the first read of event.data, followed by the handler body. Measurement brackets below show serialize as t1 minus t0 from performance.now on either side of the call, delivery as arrivedAt minus postedAt on one epoch scale, and deserialize as d1 minus d0 around the first .data read. One postMessage, three measurable intervals time → t0 · postedAt t1 arrivedAt · d0 d1 Main thread sender app code worker.postMessage(payload) serialize runs here · sender blocked call returned — sender is free again delivery queue wait + receiver wake-up blocks neither thread Worker thread receiver idle — the message has not been dispatched yet read event.data deserialize handler body serialize = t1 − t0 performance.now() either side of the call arrivedAt − postedAt one epoch scale deserialize = d1 − d0 the first .data read Collapse any two of these into one number and a queue-wait problem looks exactly like a clone problem.
Only the first interval blocks the UI, and only the third and first are affected by payload shape — which is why one end-to-end number cannot tell you what to fix.

Minimal Reproducible Example

The smallest useful measurement needs no worker at all. structuredClone() runs the same serializer and deserializer that postMessage uses, synchronously, on the calling thread — which makes it the cleanest baseline available, free of scheduling noise:

// clone-cost.ts — main thread, no worker required
export function medianCloneMs(payload: unknown, runs = 20): number {
  const samples: number[] = [];

  for (let i = 0; i < runs + 3; i++) {
    const t0 = performance.now();
    structuredClone(payload);          // serialize + deserialize, both here
    const elapsed = performance.now() - t0;
    if (i >= 3) samples.push(elapsed); // discard 3 warm-up runs
  }

  samples.sort((a, b) => a - b);
  return samples[Math.floor(samples.length / 2)];
}

const flat = new Uint8Array(5 * 1024 * 1024);            // 5 MB, zero-filled
console.log(`clone: ${medianCloneMs(flat).toFixed(2)} ms`);
// clone: 3.52 ms

The sender-side half of a real postMessage is measured the same way, because V8 performs the serialize step inside the call rather than deferring it:

const t0 = performance.now();
worker.postMessage(payload);           // serialization happens here, synchronously
const serializeMs = performance.now() - t0;

That two-line pattern is what you attach to a suspect call site in production code. If serializeMs exceeds 2 ms you have found a frame-budget problem without opening DevTools.

Step-by-Step Walkthrough

const t0 = performance.now();
structuredClone(payload);

The payload is built outside the timed region. new Uint8Array(5 * 1024 * 1024) zero-fills 5 MB, which costs roughly as much as the clone itself; allocating inside the loop would silently double every number you report.

if (i >= 3) samples.push(elapsed);

V8 compiles the serializer path for a given payload shape just in time. The first two or three runs against a new shape are routinely 3–5× slower than the steady state. Three discarded runs is the minimum; for object graphs with many distinct hidden classes, discard five.

samples.sort((a, b) => a - b);
return samples[Math.floor(samples.length / 2)];

Median, never mean. A single GC pause landing inside one sample moves a 20-run mean by several milliseconds while leaving the median untouched. Report the median and, if you care about tail behaviour, the 95th percentile alongside it — but do not average.

worker.postMessage(payload);

Note what this line does not let you observe: once it returns, the message is a SerializedScriptValue in a queue. Everything after this point requires a timestamp from the other thread — and that is where the clocks stop agreeing.

Aligning the Two Clocks

performance.now() returns milliseconds since that context’s time origin. For a document, the origin is navigation start. For a WorkerGlobalScope, the origin is the moment that global scope was created. A worker spawned eight seconds into a page’s life therefore reports performance.now() values about 8000 ms lower than the main thread’s for the same instant, which is exactly how “negative latency” appears in a naive harness.

The fix is to lift both threads onto absolute epoch milliseconds before subtracting:

// timing.ts — imported by both the main thread and the worker
export const nowEpochMs = (): number =>
  performance.timeOrigin + performance.now();
// harness.ts — main thread
interface ClonePing {
  kind: 'clone';
  postedAt: number;      // epoch ms, sender's scale
  data: Uint8Array;
}

const worker = new Worker(new URL('./echo.worker.ts', import.meta.url), {
  type: 'module',
});

function sendClone(data: Uint8Array): void {
  const postedAt = nowEpochMs();
  const t0 = performance.now();
  worker.postMessage({ kind: 'clone', postedAt, data } satisfies ClonePing);
  console.log(`serialize: ${(performance.now() - t0).toFixed(2)} ms`);
}
// echo.worker.ts — receiving side
self.onmessage = (event: MessageEvent) => {
  // First statement: nothing may run before the arrival stamp.
  const arrivedAt = performance.timeOrigin + performance.now();

  // Blink deserialises the payload on the first read of .data, so bracket it.
  const d0 = performance.now();
  const msg = event.data;
  const deserializeMs = performance.now() - d0;

  self.postMessage({
    kind: 'report',
    deliveryMs: arrivedAt - msg.postedAt,
    deserializeMs,
    payloadBytes: msg.data.byteLength,
  });
};

Two details carry the measurement. The arrival stamp is the first statement of the handler, so no handler logic inflates the delivery number. And the deserialize bracket wraps the first access to event.data: in Blink the MessageEvent holds the serialized value and materialises the object graph lazily on that getter, so bracketing it isolates deserialization from everything else. Firefox and WebKit may deserialize before dispatch, in which case deserializeMs reads near zero on those engines — a zero there means “already paid”, not “free”.

Embedding postedAt in the message body rather than a side channel keeps the timestamp and the payload strictly ordered. The number itself costs 8 bytes to clone, which is below the noise floor.

Two time origins, and the epoch scale that reconciles them Three horizontal time axes. The document axis starts at navigation start and reads 8420.5 milliseconds at the marked instant. The worker axis begins only where the worker global scope was created, 8000 milliseconds into the page, so it reads 420.5 milliseconds at that same physical instant. Subtracting the raw readings yields 8000 milliseconds of latency that never happened. The third axis maps both onto absolute epoch milliseconds using performance.timeOrigin plus performance.now, where both threads name the same instant with the same number. Two time origins, one physical instant the same physical instant Document performance.now() navigation start new Worker() 0.0 8000.0 8420.5 Worker performance.now() time origin = worker global scope created 0.0 420.5 Raw subtraction: 8420.5 − 420.5 = 8000 ms of latency that never happened post the message later in the page's life and the same arithmetic goes negative Epoch ms timeOrigin + now() …600 000.0 …608 000.0 …608 420.5 Leading digits elided. On the shared scale both threads name the instant identically, so delivery finally means something.
The worker's clock starts when the worker does. Lift both sides onto performance.timeOrigin + performance.now() before you subtract anything across the boundary.

Clone Versus Transfer for the Same Bytes

Once both sides agree on a clock, run the identical payload down both paths. The transfer path hands the buffer over by pointer, so it should collapse the serialize interval to a constant regardless of size — this is the empirical basis for choosing Transferable Objects & Zero-Copy over a plain clone:

function sendTransfer(buffer: ArrayBuffer): void {
  const postedAt = nowEpochMs();
  const t0 = performance.now();
  worker.postMessage({ kind: 'transfer', postedAt, buffer }, [buffer]);
  console.log(`handover: ${(performance.now() - t0).toFixed(2)} ms`);
  // buffer.byteLength === 0 from here on — the main thread no longer owns it
}

Run at least ten iterations of each strategy after the warm-up, alternating strategies rather than running all clones then all transfers, so that thermal throttling and GC pressure affect both arms equally.

What the Numbers Look Like

Median of 20 runs each, Chrome 124, Intel Core i7-1185G7, 16 GB RAM, performance power profile, page cross-origin isolated so timers are unquantized:

Payload (Uint8Array) Serialize (ms) Transfer handover (ms) Ratio
100 KB 0.08 0.05 1.6×
500 KB 0.35 0.05
1 MB 0.72 0.06 12×
5 MB 3.6 0.07 51×
10 MB 7.2 0.08 90×
50 MB 38 0.11 345×

Flat typed arrays clone at roughly 0.7 ms per megabyte because the serializer treats their backing store as a block copy with a header. Byte count is a poor predictor for anything else. Holding total size fixed at 1 MB and varying only the shape of the graph:

1 MB payload, varying shape Median structuredClone (ms)
Uint8Array (1 048 576 elements) 0.72
Float64Array (131 072 elements) 0.70
20 000 flat objects, 12 fields each 9.4
Map with 50 000 string keys 14.1
Nested tree, depth 12, 40 000 nodes 21.3

The 30× spread between the first and last row is the whole argument for benchmarking a representative fixture rather than a synthetic buffer. Every object, key and string in a graph is visited, checked against the reference table that gives structured clone its cycle handling, and re-allocated on the receiving heap.

Serialize cost versus transfer handover, and the cost of payload shape Two logarithmic bar charts of the page's measurements. The upper chart pairs clone serialize time against transfer handover time for six payload sizes: at 100 kilobytes 0.08 against 0.05 milliseconds, at 500 kilobytes 0.35 against 0.05, at 1 megabyte 0.72 against 0.06, at 5 megabytes 3.6 against 0.07, at 10 megabytes 7.2 against 0.08, and at 50 megabytes 38 against 0.11. The clone bars grow with size while the transfer bars stay almost flat. The lower chart holds size fixed at one megabyte and varies shape: Uint8Array 0.72 milliseconds, Float64Array 0.70, twenty thousand flat objects 9.4, a Map of fifty thousand string keys 14.1, and a nested tree of forty thousand nodes 21.3 — an order of magnitude above the typed arrays. Serialize versus transfer handover — median of 20 runs clone (serialize) transfer (handover) log scale, milliseconds 0.5 ms rule of thumb 100 KB 500 KB 1 MB 5 MB 10 MB 50 MB 0.08 0.05 0.35 0.05 0.72 0.06 3.6 0.07 7.2 0.08 38 0.11 0.01 0.1 1 10 100 ms Transfer handover is flat: ownership does not scale with payload size. The same 1 MB, five shapes — median structuredClone same log scale Uint8Array Float64Array 20 000 objects Map, 50 000 keys tree, 40 000 nodes 0.72 ms 0.70 ms 9.4 ms 14.1 ms 21.3 ms 0.01 0.1 1 10 100 ms Identical byte count, 30× spread — benchmark a representative fixture, not a synthetic buffer.
Chrome 124, Core i7-1185G7, cross-origin isolated. Read the top panel to decide clone versus transfer; read the bottom panel to see why byte count alone never predicts the answer.
Rule of thumb

Transfer any payload whose measured serialize cost exceeds 0.5 ms and which crosses the boundary more than once per second — for flat typed arrays that threshold lands near 500 KB, for object graphs it can arrive before 100 KB. Below it, keep the clone: the simpler code is worth more than the microseconds.

Reading the Same Split in a Performance Trace

The instrumented numbers should agree with what the profiler shows, and disagreement usually means your timestamps are in the wrong place. In a Chrome trace with Include worker threads enabled, the serialize step appears on the sending thread as a task attributed to the postMessage call site, immediately followed on the receiving thread by the deserialize work inside the message-event task. Search the flame chart for serialize and click a block to see its duration and originating stack frame.

The practical reading rule: a serialize block under 0.5 ms is noise, one over 2 ms is a direct contributor to dropped frames against a 16.7 ms budget at 60 fps. For the full trace-reading workflow — worker track setup, performance.mark() bands, and separating compute from overhead — see Profiling Worker CPU Usage with the Chrome Performance Tab.

The same three intervals as the profiler draws them A stylised Chrome flame chart against a 16.7 millisecond frame budget. On the main-thread track a task contains a pointerdown event, which contains sendClone, which contains building the 5 megabyte payload and then worker.postMessage; nested inside postMessage is a 3.6 millisecond serialize block, about 22 percent of the frame budget. A dashed arrow marks roughly 0.75 milliseconds of delivery down to the worker track, where the message-event task opens with a 3.5 millisecond deserialize block before the handler body runs. Chrome trace 16.7 ms frame budget at 60 fps frame deadline Main thread flame chart Task Event: pointerdown sendClone() build 5 MB worker.postMessage sync tail serialize · 3.6 ms 3.6 ms — 22% of one frame over 2 ms contributes to dropped frames delivery ≈ 0.75 ms Worker echo.worker.ts Task Event: message deserialize · 3.5 ms your handler The worker's deserialize is a prefix of the message-event task — it runs before your handler does. If the flame chart and your performance.now() numbers disagree, the timestamps are in the wrong place.
Two blocks, two tracks, one frame: the profiler view and the instrumented numbers should describe the same 3.6 ms serialize and 3.5 ms deserialize.

Gotchas & Edge Cases

Timer quantization hides sub-millisecond differences

Outside a cross-origin-isolated context, performance.now() is rounded to 100 µs as a Spectre mitigation. A payload that clones in 0.30 ms is therefore measured with roughly ±20% error, and two candidate payload shapes that differ by 50 µs are indistinguishable. Serve the benchmark page with Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp to get 5 µs resolution — the same isolation SharedArrayBuffer requires, which is convenient if you are also evaluating postMessage vs SharedArrayBuffer: When to Choose Each. Be aware that the headers will block third-party iframes and images that do not opt in with CORS or CORP, so isolate a dedicated benchmark page rather than your whole app.

The return trip is a second clone you forgot to count

The harness above measures one direction. A worker that receives 5 MB, processes it, and posts a 5 MB result back pays the serialize cost twice — 7.2 ms per round trip on the hardware above, not 3.6 ms. Measure both legs, and transfer the result buffer back rather than cloning it:

// worker: hand ownership back instead of copying
self.postMessage({ kind: 'result', buffer: out }, [out]);

A detached buffer measures as instant because there is nothing left to copy

After a transfer, the sender’s ArrayBuffer has byteLength === 0. Re-sending the same variable in a loop therefore transfers nothing and reports an impressively flat 0.02 ms, which looks like a spectacular win and is in fact a benchmark measuring an empty buffer. Allocate a fresh buffer per iteration in the transfer arm, and assert buffer.byteLength > 0 immediately before each post.

Warm-up applies per payload shape, not per harness

Discarding three runs at the start of the whole benchmark does not warm the serializer for a shape introduced in run 40. If the suite sweeps several payload shapes, restart the warm-up inside each shape’s loop, or the first shape’s numbers will be optimistic relative to the rest.

Performance Note

The single number worth carrying away: flat typed arrays serialize at about 0.7 ms per megabyte, and every other shape is slower — by 10× to 30× for dense object graphs. Converted into a frame budget, one 5 MB typed-array clone consumes roughly a fifth of a 16.7 ms frame, and the same 5 MB expressed as 100 000 plain objects consumes several frames outright. Anything crossing the boundary at animation frequency should be a transferable ArrayBuffer with the structure encoded in the bytes, not an object graph handed to the serializer.

Frequently Asked Questions

Can I get the serialize cost by wrapping performance.now() around the postMessage call?
For the sender’s half, yes. In V8 the structured-serialize step runs synchronously inside worker.postMessage(payload), so the gap between a timestamp taken immediately before the call and one taken immediately after is real main-thread blocking time. What that gap does not contain is the queue wait, the receiving thread’s wake-up, or the deserialize step on the other side — those happen after the call has already returned. Measure the sender-side gap for jank attribution, and structuredClone(payload) in isolation when you want serialize plus deserialize as one number without a thread hop.
Are performance.now() values from a worker comparable with values from the main thread?
No — not raw. A WorkerGlobalScope gets its own time origin, set when that global scope is created, so a worker’s performance.now() counts from worker startup while the document’s counts from navigation. Subtracting one from the other produces a number that is wrong by however long the page had been running when the worker spawned. Convert both sides to the same absolute scale with performance.timeOrigin + performance.now() before subtracting. Expect residual noise of about 0.1 ms because timers are quantized to 100 µs unless the page is cross-origin isolated with Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp, which restores 5 µs resolution.

See also