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.
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.
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 | 7× |
| 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.
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.
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.