postMessage Bottleneck Analysis

postMessage looks like a pointer hand-off and behaves like a deep copy. Every value you pass across the thread boundary is walked by the Structured Clone Algorithm, re-allocated on the receiving heap, and — critically — serialized synchronously on the sending thread, which means a slow message is indistinguishable from a slow function call as far as your frame budget is concerned. This guide is part of the Debugging, Profiling & Production Optimization reference, and it walks the full diagnostic arc: reproduce the cost, split it into serialize, deliver and execute, attribute it in a trace, then remove it by reshaping the payload, transferring instead of copying, and throttling the stream.

If you only want the isolated clone-cost snippet, Measuring Structured Clone Cost with performance.now() has a paste-ready harness. For the mechanics of what the algorithm actually does to your object graph, see the Step-by-Step Guide to the Structured Clone Algorithm.


The Symptom You Are Debugging

The canonical case: a live scatter plot of 250,000 points, rendered on the main thread, with filtering and aggregation moved into a worker so “the heavy work is off the UI thread”. The user drags the time-range brush. The worker finishes its aggregation in 6 ms. And yet the interaction stutters, the Performance panel shows a 41 ms long task on the main thread, and the flame chart attributes almost none of it to your own functions.

The shape of the evidence is distinctive:

  • A long task on Main whose self time sits in a browser-internal frame, not in your code.
  • The task begins immediately after your event handler calls worker.postMessage(...).
  • Worker Run Script time is small — the worker is not the problem.
  • The stutter scales with the number of rows, not the amount of computation, and it gets worse as you add fields to each row rather than as you add bytes.

That is a serialization bottleneck. The thread hop itself costs microseconds; the deep copy of an object graph with hundreds of thousands of nodes costs tens of milliseconds, paid twice — once on the sender to serialize, once on the receiver to materialize. Because the sender side is synchronous, the main thread is blocked for the whole serialize phase, which is exactly the jank you are seeing. The same failure mode shows up in CSV ingestion pipelines, in image tiles round-tripped as plain arrays, and in any worker pool whose task descriptors carry the data instead of a handle to it.

Anatomy of one janked frame caused by postMessage serialization A single 41 millisecond main-thread task triggered by a pointerdown. Three milliseconds are the page's own handler; thirty-two milliseconds are structured-clone serialization inside the postMessage call, during which the main thread cannot paint; a six millisecond tail closes the task. The 16.7 millisecond frame budget runs out a quarter of the way into the serialize block, so the frame is dropped. On the worker track the thread is idle for the whole serialize phase and then spends six milliseconds deserializing and aggregating. pointerdown · t = 0 16.7 ms frame budget 24 ms over budget — this frame never paints time → Main thread one 41 ms task your code 3 ms Structured clone — serialize · 32 ms synchronous on the sender: the UI cannot paint task tail 6 ms thread hop ≈ 50 µs Worker aggregate() idle — the message is still being serialized deserialize + compute 6 ms The flame chart blames a browser-internal frame — the cost is the copy, not your code.
The worker was never the problem: it finished in 6 ms. The frame died inside worker.postMessage(...), four times over budget before a single byte crossed the thread boundary.

Prerequisites

Before you can attribute a single millisecond correctly, get these in place:

  • Chrome DevTools with worker tracks enabled. In the Performance panel, worker threads appear as their own tracks; confirm you can see them before recording. Chrome DevTools Worker Debugging covers attaching to the worker isolate and reading its tracks.
  • Module workers. Create workers with new Worker(url, { type: 'module' }) so source maps resolve and frames in the worker flame chart carry real function names.
  • A payload builder you can call repeatedly. Benchmarks that clone the same object twice measure a warm heap, not your workload.
  • structuredClone() available (Chrome 98+, Firefox 94+, Safari 15.4+) so you can time the algorithm without a thread hop.
  • A known-bad scenario you can trigger on demand — e.g. “drag the brush across the full range”, or “send 100 frames of 1 MB each in a tight loop”.
  • Cross-origin isolation checked, if you intend to evaluate shared memory: crossOriginIsolated must be true before SharedArrayBuffer even exists.

Step 1 — Reproduce the Bottleneck Deterministically

Ad-hoc console.time calls around one postMessage produce numbers that swing by 5× between runs, because the first call warms an inline cache and later calls hit a heap that has already grown. The harness below rebuilds the payload each run, takes medians rather than means, and records three separate quantities: pure clone cost, sender-side blocking cost, and end-to-end round trip.

// bench/postmessage-bench.ts — runs on the main thread
export interface CloneSample {
  label: string;
  bytes: number;
  cloneMs: number;      // structuredClone(): serialize + deserialize, no thread hop
  postMs: number;       // synchronous blocking cost of the postMessage() call itself
  roundTripMs: number;  // dispatch -> worker -> reply -> main thread
}

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

const median = (xs: number[]): number =>
  [...xs].sort((a, b) => a - b)[xs.length >> 1];

function approximateBytes(value: unknown): number {
  if (ArrayBuffer.isView(value)) return value.byteLength;
  return new TextEncoder().encode(JSON.stringify(value)).byteLength;
}

export async function sample(
  label: string,
  build: () => unknown,
  runs = 25,
): Promise<CloneSample> {
  const clone: number[] = [];
  const post: number[] = [];
  const trip: number[] = [];

  for (let i = 0; i < runs; i++) {
    // Rebuild every iteration: re-cloning one warm graph measures the cache, not the work.
    const payload = build();

    const c0 = performance.now();
    structuredClone(payload);
    clone.push(performance.now() - c0);

    const t0 = performance.now();
    worker.postMessage({ id: i, payload });
    post.push(performance.now() - t0);

    await new Promise<void>((resolve) => {
      const onReply = (e: MessageEvent<{ id: number }>) => {
        if (e.data.id !== i) return;          // ignore replies from earlier runs
        worker.removeEventListener('message', onReply);
        trip.push(performance.now() - t0);
        resolve();
      };
      worker.addEventListener('message', onReply);
    });
  }

  return {
    label,
    bytes: approximateBytes(build()),
    cloneMs: median(clone),
    postMs: median(post),
    roundTripMs: median(trip),
  };
}

The worker side deliberately does no work, so the round trip measures transport only:

// echo.worker.ts — module worker, no computation
self.onmessage = (e) => {
  // Reply with the id alone: sending the payload back would double the measurement.
  self.postMessage({ id: e.data.id });
};

Running that across payload shapes is what turns “postMessage is slow” into an actionable number. Indicative medians from the harness above on a mid-range 2023 desktop, Chrome 126 — re-run it on your own target hardware rather than trusting these figures:

Payload Approx. bytes structuredClone postMessage (sender) Round trip
Float32Array(262144) 1.0 MB 0.5 ms 0.3 ms 0.9 ms
20,000 rows × 12 fields 1.1 MB 11 ms 6 ms 14 ms
Nested tree, 50,000 nodes 1.0 MB 26 ms 15 ms 31 ms
Map with 100,000 entries 2.9 MB 48 ms 27 ms 57 ms
Float32Array(262144) transferred 1.0 MB n/a 0.02 ms 0.3 ms

Four payloads of roughly the same size differ by two orders of magnitude. Cost tracks the number of distinct objects, keys and strings the algorithm must visit, not the byte count — a typed array is one buffer descriptor plus a memcpy, while 20,000 row objects are 20,000 allocations with 240,000 property writes on the far side.

Sender-side postMessage cost by payload shape, on a logarithmic scale Horizontal bars on a logarithmic axis from 0.01 to 100 milliseconds. A one megabyte Float32Array blocks the sender for 0.3 milliseconds; 20,000 row objects with twelve fields cost 6 milliseconds; a 50,000 node tree costs 15 milliseconds; a Map of 100,000 entries costs 27 milliseconds; the same Float32Array sent in the transfer list costs 0.02 milliseconds. Every payload is roughly one megabyte, so cost tracks the number of objects, keys and strings rather than the byte count. Median blocking cost of one postMessage — log scale all payloads ≈ 1 MB Float32Array · 1.0 MB 20,000 rows × 12 fields tree, 50,000 nodes Map, 100,000 entries Float32Array, transferred 0.3 ms 6 ms 15 ms 27 ms 0.02 ms 0.01 0.1 1 10 100 ms Same size, two orders of magnitude apart: cost tracks object count, not bytes. Chrome 126, mid-range 2023 desktop — medians of 25 runs; re-run on your own hardware.
The same five rows as the table, drawn on a log axis so the 1,350× spread between a transferred buffer and a cloned Map is visible at a glance.
Measure object count, not megabytes

Before optimising, log rows.length and the field count per row alongside the byte size. A payload that halves in bytes but keeps the same object count will barely get faster; a payload that keeps its bytes but collapses 20,000 objects into 12 typed arrays typically gets 20–50× faster to clone.


Step 2 — Separate Serialization from Delivery and Execution

The single most common mistake in this analysis is treating round-trip latency as one number. It is three: sender serialize, queue plus receiver deserialize, and actual compute. Only the first blocks your UI; only the last is work you actually wanted. Splitting them requires a timestamp that is meaningful on both threads.

Each worker has its own performance.timeOrigin, set when the worker global scope is created — so raw performance.now() values from the two threads are not comparable. Normalise to absolute epoch milliseconds on both sides:

// shared/clock.ts — imported by both the main thread and the worker
export const nowAbs = (): number => performance.timeOrigin + performance.now();

export interface TimedRequest<T> {
  kind: 'work';
  sentAtAbs: number;   // absolute ms, comparable across threads
  payload: T;
}

export interface TimedReply {
  kind: 'result';
  sentAtAbs: number;
  arrivedAtAbs: number;   // worker saw the message here
  computeMs: number;      // worker's own execution time
  replyAtAbs: number;
}
// main.ts
import { nowAbs, type TimedRequest, type TimedReply } from './shared/clock';

function dispatch<T>(worker: Worker, payload: T): number {
  const t0 = performance.now();
  const message: TimedRequest<T> = { kind: 'work', sentAtAbs: nowAbs(), payload };
  worker.postMessage(message);
  return performance.now() - t0;   // main-thread blocking time: serialize only
}

worker.addEventListener('message', (e: MessageEvent<TimedReply>) => {
  const { sentAtAbs, arrivedAtAbs, computeMs, replyAtAbs } = e.data;
  const inboundMs = arrivedAtAbs - sentAtAbs;        // queue wait + deserialize
  const outboundMs = nowAbs() - replyAtAbs;          // reply clone + delivery
  const transportMs = inboundMs + outboundMs;
  const totalMs = nowAbs() - sentAtAbs;

  console.table({
    transportMs: +transportMs.toFixed(2),
    computeMs: +computeMs.toFixed(2),
    transportRatio: +(transportMs / totalMs).toFixed(3),
  });
});
// worker.ts — module worker
import { nowAbs } from './shared/clock';

self.onmessage = (e) => {
  const arrivedAtAbs = nowAbs();       // first statement: the clone is already paid for
  const { sentAtAbs, payload } = e.data;

  const c0 = performance.now();
  const result = aggregate(payload);   // the work you actually wanted
  const computeMs = performance.now() - c0;

  self.postMessage({
    kind: 'result',
    sentAtAbs,
    arrivedAtAbs,
    computeMs,
    replyAtAbs: nowAbs(),
    result,
  });
};

arrivedAtAbs - sentAtAbs bundles queue wait with deserialize, which is fine — both are consequences of the message being large, and neither is separable from user-space JavaScript. What matters is the ratio transportMs / totalMs. Below 0.15 the transport is noise. Above 0.5 you are running a copying machine that occasionally does arithmetic.

Watch the queue wait, not just the clone

If inboundMs grows steadily across a burst while sender-side postMessage time stays flat, the worker is behind: messages are sitting in the queue. That is a backpressure problem, not a serialization problem, and it is fixed by Step 6, not by transferables.

One round trip split into the four transport phases and the compute phase A waterfall of a 24 millisecond round trip. Sender serialization takes 6 milliseconds and is the only phase that blocks the UI; queue wait takes 1.5 milliseconds; worker deserialization takes 5 milliseconds; the aggregation you actually wanted takes 9 milliseconds; the reply clone and delivery take 2.5 milliseconds. Transport therefore accounts for 15 of the 24 milliseconds, a ratio of 0.63, which lands in the band where the message boundary dominates rather than the acceptable band below 0.35 or the noise band below 0.15. 0 24.0 ms total sender serialize queue wait worker deserialize worker compute reply clone + delivery 6.0 ms 1.5 ms 5.0 ms 9.0 ms 2.5 ms transport ratio noise acceptable the boundary dominates measured 0.63 0 0.15 0.35 1.0 transport = 6.0 + 1.5 + 5.0 + 2.5 = 15.0 ms of a 24.0 ms trip Only the compute bar is work you wanted; only the first bar blocks the UI.
The same trip the console prints as one number. Serialize is billed to the sender, deserialize to the receiver, and the ratio — not the total — is what decides whether transport is worth optimising.

Step 3 — Confirm the Split in a Performance Trace

Instrumented timings tell you the size of the problem; a trace tells you where it lands relative to frames, garbage collection and rendering. Record with worker tracks included, then make the worker emit User Timing entries so its internal phases become labelled spans instead of an anonymous block.

  1. Open Performance, click the settings gear, and make sure worker threads are captured (each dedicated worker gets its own track under the main frame).
  2. Trigger the known-bad scenario and record 5–10 seconds. Do not record the very first interaction — capture a warmed-up steady state.
  3. On the Main track, find the long task and expand it. A structured-clone bottleneck shows as a wide internal frame directly beneath your event handler, with negligible self time in your own functions.
  4. Switch to the worker track and look at the gap between the message arriving and your first user-timing mark. That gap is deserialize.

The marks that make step 4 readable:

// worker.ts — emit User Timing so the trace shows named spans on the worker track
import { nowAbs } from './shared/clock';

self.onmessage = (e) => {
  const { sentAtAbs, payload } = e.data;

  // Convert the sender's absolute timestamp into this worker's own timeline
  // so the measure lines up with the surrounding trace events.
  const sentRelative = sentAtAbs - performance.timeOrigin;
  if (sentRelative > 0) {
    performance.measure('clone:inbound', {
      start: sentRelative,
      end: performance.now(),
    });
  }

  performance.mark('aggregate:start');
  const result = aggregate(payload);
  performance.measure('aggregate', 'aggregate:start');

  self.postMessage({ kind: 'result', result, replyAtAbs: nowAbs() });
};

User Timing entries recorded inside a worker appear on that worker’s track in the Performance panel, so clone:inbound sits visually adjacent to aggregate and the ratio is readable at a glance. Profiling Worker CPU Usage with the Chrome Performance Tab covers reading the resulting flame chart in detail, including how to tell a genuine compute frame from a deoptimisation storm.

Two trace artefacts routinely mislead people here:

  • The profiler tax. V8’s sampling hooks add roughly 5–15% overhead while recording. Trust the ratio between phases from a trace; trust the absolute numbers from the uninstrumented harness in Step 1.
  • Breakpoints hide backpressure. Pausing a worker does not pause the main thread. Messages keep arriving and queueing, so when you resume, the worker chews through a burst that never existed in production and the timeline looks pathological.
Where the clone halves appear on the Main and Worker tracks of a Performance trace A Performance panel excerpt with two tracks. The Main track holds a 47 millisecond task for the brush drag handler; inside it a dashed block marks roughly 32 milliseconds of structured cloning that the panel never labels. An initiator arrow crosses to the Worker track, where the message task opens with an unlabelled gap — that gap is deserialization — followed by the aggregation. Below, the Timings row shows the two user-timing measures emitted from inside the worker, clone:inbound spanning the gap and aggregate spanning the compute. Performance panel · recorded with worker tracks enabled Main Task · 47 ms brush drag handler structured clone ≈ 32 ms · never labelled initiator Event: message · 38 ms Worker aggregate.worker unlabelled gap aggregate() runs here Timings clone:inbound aggregate The gap before the first mark is deserialization — the panel never names it. Trust ratios from the trace (5–15% profiler tax); trust absolute numbers from the Step 1 harness.
The two user-timing measures are what turn an anonymous worker block into a readable split: everything before clone:inbound ends is transport, everything inside aggregate is work.

Step 4 — Reshape the Payload Before You Change the Transport

Reaching straight for transferables is the reflex, but the cheapest fix is usually structural: stop sending an object graph at all. A columnar layout replaces N row objects with a fixed number of typed arrays, which the clone algorithm handles as buffers rather than as graphs — and which are transferable for free once you get there.

// shared/columnar.ts
export interface PointRow {
  t: number;        // epoch ms
  value: number;
  seriesId: number;
  flags: number;
}

/** Columnar form: 4 buffers instead of rows.length objects. */
export interface PointColumns {
  count: number;
  t: Float64Array;
  value: Float32Array;
  seriesId: Uint16Array;
  flags: Uint8Array;
}

export function toColumns(rows: readonly PointRow[]): PointColumns {
  const count = rows.length;
  const cols: PointColumns = {
    count,
    t: new Float64Array(count),
    value: new Float32Array(count),
    seriesId: new Uint16Array(count),
    flags: new Uint8Array(count),
  };

  for (let i = 0; i < count; i++) {
    const r = rows[i];
    cols.t[i] = r.t;
    cols.value[i] = r.value;
    cols.seriesId[i] = r.seriesId;
    cols.flags[i] = r.flags;
  }
  return cols;
}

/** Every underlying buffer, ready for the postMessage transfer list. */
export function columnBuffers(cols: PointColumns): ArrayBuffer[] {
  return [cols.t.buffer, cols.value.buffer, cols.seriesId.buffer, cols.flags.buffer];
}

For the 20,000-row payload in the Step 1 table, this conversion drops clone cost from ~11 ms to ~0.4 ms even before transferring, because the algorithm now visits four buffers instead of 20,000 objects and 240,000 properties. The conversion loop itself costs about 1.5 ms — pay it once, on whichever side already owns the data, and never rebuild rows just to send them.

Three payload-shape rules that fall out of the same reasoning:

  • Strings are expensive per instance. Repeated category labels should become a Uint16Array of indices into a dictionary sent once at handshake time, not a string per row.
  • Map and Set are graph structures. They clone entry by entry. A Map of 100,000 entries is 100,000 key clones plus 100,000 value clones.
  • Don’t send what the receiver can derive. Ship raw columns and let the worker compute the derived fields; the arithmetic is cheaper than the copy.
Reshape first, transfer second

Reshaping is compatible with every browser, needs no headers, and does not detach anything. Transferables then make an already-cheap clone effectively free. Doing it in the other order — transferring an object graph you cannot transfer — is why so many "we tried transferables and it didn't help" reports exist: only ArrayBuffer, MessagePort, ImageBitmap, OffscreenCanvas and the stream types are transferable, and a plain array of objects is none of them.

Row-oriented object graph versus columnar typed arrays Two panels compare the same one megabyte of point data. On the left the message root fans out to 20,000 row objects, each carrying twelve field slots, so the clone algorithm visits every object, key and string and re-allocates it on the far heap, costing about 11 milliseconds. On the right the same data is four contiguous typed arrays — a Float64Array of timestamps, a Float32Array of values, a Uint16Array of series identifiers and a Uint8Array of flags — which clone as four buffers in about 0.4 milliseconds and are transferable as they stand. Row-oriented — 20,000 objects Columnar — 4 typed arrays message row 0 row 1 row 19,999 every object, key and string is visited, hashed for cycles and re-allocated structuredClone ≈ 11 ms 20,000 objects · 240,000 property writes message t · Float64Array value · Float32Array seriesId · Uint16Array flags · Uint8Array 160 KB 80 KB 40 KB 20 KB structuredClone ≈ 0.4 ms 4 buffers · and every one is transferable toColumns() costs ~1.5 ms once — and it is the only fix that needs no new API. ≈ 27× cheaper to clone, before a single byte is transferred.
Same bytes, different topology. The clone algorithm charges by graph nodes, so collapsing rows into columns is a bigger win than any transport change — and it is what makes the transport change possible.

Step 5 — Move the Bulk Path to Transferable Objects

Once the payload is columnar, ownership can move instead of being copied. Passing a buffer in the transfer list detaches it from the sending thread — byteLength becomes 0 and any view over it throws on access — and the receiving thread gets the same memory with no serialization at all. This is the mechanism described in Transferable Objects & Zero-Copy; the pattern below adds the piece that guide leaves to the caller: a bounded, backpressure-aware pipeline that also recycles buffers.

// main.ts — bounded transfer pipeline with buffer recycling
interface Chunk {
  seq: number;
  buffer: ArrayBuffer;
}

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

const MAX_IN_FLIGHT = 2;          // never let more than 2 chunks sit in the worker queue
const pending: Chunk[] = [];
const freeList: ArrayBuffer[] = []; // buffers returned by the worker, ready to refill
let inFlight = 0;
let dropped = 0;

function submit(chunk: Chunk): void {
  pending.push(chunk);
  pump();
}

function pump(): void {
  while (inFlight < MAX_IN_FLIGHT && pending.length > 0) {
    const chunk = pending.shift() as Chunk;
    inFlight++;
    // After this call chunk.buffer is detached on this thread. Do not read it.
    worker.postMessage({ kind: 'process', ...chunk }, [chunk.buffer]);
  }
}

worker.addEventListener('message', (e: MessageEvent<{ kind: string; buffer: ArrayBuffer }>) => {
  if (e.data.kind !== 'processed') return;
  inFlight--;
  freeList.push(e.data.buffer);   // ownership came back: reuse instead of reallocating
  pump();
});

/** Acquire a buffer of the required size, preferring a recycled one. */
function acquire(byteLength: number): ArrayBuffer {
  const idx = freeList.findIndex((b) => b.byteLength === byteLength);
  return idx >= 0 ? (freeList.splice(idx, 1)[0] as ArrayBuffer) : new ArrayBuffer(byteLength);
}
// process.worker.ts — transfer the same buffer straight back
self.onmessage = (e) => {
  const { kind, seq, buffer } = e.data;
  if (kind !== 'process') return;

  const view = new Float32Array(buffer);
  for (let i = 0; i < view.length; i++) view[i] = Math.fround(view[i] * 2);

  // Returning the buffer in the transfer list keeps the allocation count flat:
  // the same memory ping-pongs between threads for the life of the session.
  self.postMessage({ kind: 'processed', seq, buffer }, [buffer]);
};

Two properties make this production-grade rather than a demo. The MAX_IN_FLIGHT cap means a slow worker applies backpressure to the producer instead of silently growing an unbounded queue — the failure mode that turns a serialization problem into an out-of-memory crash. The free list means steady-state allocation is zero, which matters because a pipeline that allocates a fresh megabyte per frame will trigger major garbage collections that look, in a trace, exactly like worker slowness. If you are fanning this out across several workers, the same accounting belongs in the scheduler described in Worker Pool Management.

Detachment is permanent and silent at the call site

After postMessage(msg, [buf]), buf.byteLength is 0 and every existing TypedArray view over it is detached. Reads do not throw a helpful error — view[0] returns undefined and view.length reads 0, so bugs appear downstream as empty charts rather than exceptions. Null out your reference at the transfer site and re-derive views from the buffer you get back.

Bounded transfer pipeline with buffer recycling A sequence between the producer on the main thread, the pending queue with its in-flight gate, and the worker. A chunk is submitted, the gate releases it only while fewer than two chunks are in flight, and postMessage sends it in the transfer list: ownership moves, so the producer's buffer reports a byteLength of zero and is detached while the worker holds the same memory live. The worker transfers the buffer straight back, the producer decrements its in-flight counter, pumps the queue again, and pushes the returned buffer onto a free list for reuse instead of allocating a new one. Producer (main) pending queue Worker submit(chunk) gate: inFlight < 2 else it waits in pending postMessage(chunk, [chunk.buffer]) ownership moves — no copy chunk.buffer byteLength → 0 · detached same memory, no clone new Float32Array(buffer) postMessage({processed}, [buffer]) inFlight-- · pump() freeList reused, not reallocated The cap turns a slow worker into backpressure instead of an unbounded queue.
One buffer, ping-ponged. Nothing is copied in either direction, the in-flight cap bounds the queue, and the free list keeps steady-state allocation — and therefore major garbage collections — at zero.

Step 6 — Coalesce and Rate-Limit the Message Stream

Some bottlenecks are not about size at all. Sixty pointer-move events per second, each posting a 40 KB view-state object, cost little per message and a great deal per second. The fix is to decouple event rate from message rate: collapse everything that arrived within a frame into one message, and cap the long-run rate with a token bucket so a pathological burst sheds load instead of saturating the queue.

// main.ts — frame coalescing plus a token-bucket rate limiter
interface ViewState {
  x0: number;
  x1: number;
  zoom: number;
}

const RATE_PER_SEC = 30;
const BURST = 6;

let tokens = BURST;
let lastRefill = performance.now();
let latest: ViewState | null = null;   // only the newest state matters
let frameScheduled = false;
let coalesced = 0;
let shed = 0;

function refill(): void {
  const now = performance.now();
  tokens = Math.min(BURST, tokens + ((now - lastRefill) / 1000) * RATE_PER_SEC);
  lastRefill = now;
}

/** Called from pointermove / wheel handlers — cheap, allocates nothing. */
export function requestUpdate(state: ViewState): void {
  if (latest !== null) coalesced++;
  latest = state;
  if (frameScheduled) return;

  frameScheduled = true;
  requestAnimationFrame(() => {
    frameScheduled = false;
    const state = latest;
    latest = null;
    if (state === null) return;

    refill();
    if (tokens < 1) {
      shed++;                       // dropped on purpose — keep it observable
      return;
    }
    tokens -= 1;
    worker.postMessage({ kind: 'view', state });
  });
}

/** Report shed load so a silent drop never masquerades as a worker bug. */
export const transportStats = () => ({ coalesced, shed });

For a drag that fires 180 pointer events per second, this sends at most 30 messages per second and typically one per frame — a 6× reduction in serialize calls with no loss of fidelity, because intermediate view states were never going to be rendered anyway. The coalesced and shed counters matter more than they look: silent load-shedding is indistinguishable from a hung worker during an incident, so surface both alongside your other worker metrics. If you already forward worker errors to a backend, add these to the same payload using the conventions in Structured Error Serialization Across Threads.

Coalescing buys latency, not throughput

Collapsing to one message per frame adds up to 16.7 ms of intentional delay. That is invisible for aggregation and filtering, and clearly wrong for cursor-following crosshairs or audio parameter changes. Split those onto their own channel — a dedicated MessagePort carrying a tiny uncoalesced message — rather than lowering the batch window for everything.

Coalescing a 180 event per second stream into one message per frame Two time-aligned tracks covering 100 milliseconds of a drag, divided into six animation frames. The upper track shows about 180 pointer events per second, each tick paying a full serialize. The lower track shows the same stream after requestAnimationFrame coalescing: one batch per frame, of which the token bucket lets three through and sheds three once the 30 messages per second cap is reached. Eighteen events become three sent messages and three counted drops. 100 ms of a drag · six animation frames time → Raw events ≈ 180 / s each tick posts a message and pays serialization rAF batch over the cap one message per frame, then the token bucket decides Coalesced ≤ 30 / s sent shed sent shed sent shed 18 events → 6 frame batches → 3 messages sent, 3 shed coalesced and shed counters keep the dropped work visible in telemetry
Event rate and message rate are separate dials. Coalescing sets the first, the token bucket caps the second, and every drop is counted rather than silent.

Choosing the Data-Transfer Mechanism

Every message on this page falls into one of three transport strategies, and the right choice is decided by payload shape and ownership semantics, not by preference.

Strategy Cost per 1 MB Ownership after send Use when
Structured clone 0.5 ms (typed array) to 30 ms+ (object graph) Sender keeps its copy Small control messages, results the sender still needs, anything not transferable
Transferable ArrayBuffer ~0.02 ms, size-independent Sender’s buffer is detached Bulk numeric data, image buffers, columnar frames — the sender is done with it
SharedArrayBuffer + Atomics 0 ms for data; one notify per batch Both threads own it concurrently Continuous streaming, ring buffers, many-reader fan-out, sub-millisecond signalling

The decision procedure in practice:

  1. Is the payload a control message under a few kilobytes? Clone it. The analysis is not worth your time.
  2. Is it bulk data the sender is finished with? Reshape to typed arrays (Step 4), then transfer (Step 5). This resolves the large majority of real bottlenecks.
  3. Does the sender still need the data after sending? Either clone deliberately, or transfer and have the receiver transfer it back — which is what the free-list pipeline does.
  4. Are you sending the same buffer continuously, many times per second, or to several workers at once? Only then does shared memory pay for its complexity. SharedArrayBuffer & Atomics covers the lock-free structures this requires, and postMessage vs SharedArrayBuffer: When to Choose Each walks the trade-off head to head.
COOP / COEP required for SharedArrayBuffer

Shared memory only exists in a cross-origin isolated context. The document must be served with Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp, and every cross-origin subresource must opt in with CORS or Cross-Origin-Resource-Policy. Without those headers SharedArrayBuffer is undefined and the code path throws at construction, not at use. Gate the feature on globalThis.crossOriginIsolated === true and keep the transferable path as the fallback — isolation frequently breaks in production when a third-party embed or ad tag is added.

Decision tree for choosing between clone, transfer and shared memory Three questions asked in order about the message you are about to send. If it is a control message under a few kilobytes, structured clone it and stop analysing. If it is bulk data the sender has finished with, reshape it into typed arrays and move it in the transfer list at roughly 0.02 milliseconds per megabyte, accepting that the source detaches. If the same buffer is written continuously or read by several workers, SharedArrayBuffer with Atomics removes the data cost entirely, but only behind a cross-origin isolation gate that requires COOP and COEP headers. If none apply, transfer the buffer and have the receiver transfer it back. A message to send control message under a few KB? bulk data the sender is finished with? same buffer, many times per second? yes yes yes no no no Structured clone 0.5 ms (typed array) → 30 ms+ (object graph) the sender keeps its own copy Reshape to columns, then transfer ≈ 0.02 ms per MB, size-independent the source buffer detaches — permanently gate: crossOriginIsolated === true COOP: same-origin + COEP: require-corp SharedArrayBuffer + Atomics 0 ms for the data · one notify per batch keep the transferable path as the fallback Transfer, then transfer it back for data the sender still needs afterwards the free-list pipeline from Step 5 Costs are per 1 MB of payload.
Ownership semantics, not payload size, decide the branch: the first question is whether the analysis is worth doing at all, and the last one is whether you are prepared to pay for cross-origin isolation.

Verification & Measurement

An optimisation that is not re-measured is a guess. Verify in three passes, in this order.

1. Recompute the transport ratio. Using the instrumentation from Step 2:

const transportRatio = (transportMs: number, totalMs: number): number =>
  transportMs / totalMs;

// Acceptance thresholds used in review:
//   < 0.15  transport is noise — stop optimising the transport
//   0.15–0.35  acceptable for interactive workloads, worth revisiting
//   > 0.35  the message boundary dominates; reshape or transfer

2. Re-run the Step 1 harness on the real payload shape, before and after, and compare medians rather than single runs. A correct columnar-plus-transfer conversion of a 20,000-row frame should move sender-side postMessage cost from single-digit milliseconds into the tens of microseconds, and should make cost flat as row count grows — the giveaway that you are no longer walking a graph.

3. Confirm the long task is gone in a trace. The main-thread task that used to contain the serialize block should now be dominated by your own handler code. If a long task persists at the same place with a different internal shape, you have probably traded serialization for garbage collection — check whether you are allocating a fresh buffer per message instead of recycling. Sustained per-message allocation is also how transport work turns into a slow leak; Identifying Memory Leaks in Workers covers diffing worker heap snapshots to confirm.

A useful correctness check alongside the performance one: assert that transferred buffers really did move.

function assertDetached(buffer: ArrayBuffer, label: string): void {
  // A transferred buffer reports byteLength 0 on the sending thread.
  if (buffer.byteLength !== 0) {
    console.warn(`[transport] ${label} was cloned, not transferred — check the transfer list`);
  }
}

That one assertion catches the single most common silent regression in this area: a refactor that renames or nests the buffer so the transfer list no longer references the exact object being sent, at which point the browser quietly falls back to copying and the bottleneck returns without any error.

Sender-side postMessage cost before and after reshaping and transferring Grouped bars at three row counts. Cloned row objects cost 1.6 milliseconds at five thousand rows, 6 milliseconds at twenty thousand and 31 milliseconds at one hundred thousand, so the cost climbs with the number of objects. After converting to columnar typed arrays and sending them in the transfer list, the same payloads cost 0.02, 0.03 and 0.05 milliseconds — slivers on the axis that stay flat as the row count grows twentyfold. cloned rows (before) columnar + transferred (after) median postMessage() cost on the sender, ms 0 8 16 24 32 1.6 ms 6.0 ms 31 ms 0.02 ms 0.03 ms 0.05 ms clone cost tracks object count — it keeps climbing with row count 5,000 rows 20,000 rows 100,000 rows Flat is the proof: after reshaping and transferring, cost stops scaling with row count. The after bars are the slivers on the axis — 0.02 to 0.05 ms across a 20× range of rows.
Re-measured with the Step 1 harness. A single before/after pair at one row count proves nothing; the flat line across row counts is what tells you the graph walk is gone.

Failure Modes & Error Handling

Transport optimisation introduces failure modes that ordinary application errors do not cover. Handle all four explicitly.

DataCloneError on non-cloneable values. Functions, Symbols, DOM nodes, WeakMaps and class instances carrying methods cannot be serialized; the postMessage call throws synchronously. Class instances that do clone lose their prototype and arrive as plain objects, which usually surfaces later as x.method is not a function inside the worker. Validate at the boundary rather than deep in the receiver:

function postChecked(worker: Worker, message: unknown, transfer: Transferable[] = []): void {
  try {
    worker.postMessage(message, transfer);
  } catch (err) {
    if (err instanceof DOMException && err.name === 'DataCloneError') {
      // Almost always a function, DOM node or class instance smuggled into the payload.
      console.error('[transport] non-cloneable value in message', { message, err });
      throw err;
    }
    throw err;
  }
}

messageerror on deserialization failure. If a message serializes on the sender but cannot be deserialized on the receiver, no message event fires — a messageerror event does. Because most codebases only register onmessage, the message vanishes and the request hangs forever. Register the handler on both sides:

worker.addEventListener('messageerror', (e: MessageEvent) => {
  console.error('[transport] main thread failed to deserialize a worker message', e);
  rejectAllPending(new Error('MessageDeserializationFailed'));
});
// worker.ts
self.addEventListener('messageerror', (e) => {
  self.postMessage({ kind: 'error', name: 'MessageDeserializationFailed', detail: String(e.type) });
});

Reads from a detached buffer. As noted in Step 5, reading a detached view yields undefined and length === 0 rather than throwing. Any code path that may run after a transfer should re-acquire its view from the buffer that came back, and the assertDetached helper above should be enabled in development builds.

Unbounded queue growth. A producer faster than the consumer will grow the message queue until the tab is killed, and the browser gives you no queue-depth API to observe it. Your own inFlight counter is the only signal you get: cap it (Step 5), shed load past the cap (Step 6), and export both counters. In a worker pool, a single worker whose queue keeps growing while its siblings idle usually means a task was dispatched to a worker that has already crashed — pair the counter with a worker.onerror handler that recreates the worker and re-dispatches its outstanding tasks.

Four transport failure modes with their symptoms and guards A matrix of four cells. DataCloneError is triggered by a function, DOM node or class instance in the payload and throws synchronously at the send site; the guard is a checked send wrapper. A messageerror is triggered by a message that serializes on the sender but cannot be deserialized on the receiver, and its symptom is a request that hangs forever because no message event fires; the guard is a messageerror listener on both sides. A detached buffer read is triggered by reading a view after its buffer was transferred, and shows up as empty data rather than an exception; the guard is an assertion plus re-deriving views on return. Unbounded queue growth is triggered by a producer outrunning the consumer with no in-flight cap, and ends in the tab being killed; the guard is a cap plus an exported shed counter. Four transport failures — four different fingerprints DataCloneError Trigger — a function, DOM node or class instance Symptom — postMessage() throws synchronously Guard — postChecked() names it at the send site messageerror Trigger — serializes here, fails to deserialize there Symptom — no message event; the request hangs Guard — a messageerror listener on both sides Detached buffer read Trigger — a view read after its buffer moved Symptom — undefined and length 0, charts come back empty Guard — assertDetached(), re-derive views on return Unbounded queue growth Trigger — producer outruns consumer, no cap Symptom — memory climbs until the tab is killed Guard — MAX_IN_FLIGHT plus an exported shed counter Only the first one throws. The other three read as application bugs or as a slow worker.
Three of the four are silent by design, which is why each one needs its own guard rather than a shared try/catch: nothing in the platform will tell you a message was dropped, detached or queued forever.

Browser Compatibility

Feature Chrome Firefox Safari Edge
postMessage + structured clone 4+ 3.5+ 4+ 12+
Transferable ArrayBuffer 17+ 18+ 6+ 12+
structuredClone() global 98+ 94+ 15.4+ 98+
Transferable ImageBitmap 50+ 42+ 15+ 79+
Transferable OffscreenCanvas 69+ 105+ 16.4+ 79+
Transferable streams 87+ 103+ 16.4+ 87+
messageerror event 60+ 57+ 12.1+ 79+
performance.now() in workers 33+ 34+ 10.1+ 25+
User Timing (mark/measure) in workers 45+ 41+ 11+ 79+
Module workers ({ type: 'module' }) 80+ 114+ 15+ 80+
SharedArrayBuffer (cross-origin isolated) 92+ 79+ 15.2+ 92+
Atomics.waitAsync 87+ 132+ 16.4+ 87+

The measurement techniques on this page work everywhere: performance.now() and the transfer list have been universally supported for a decade. The compatibility cliffs are all on the optimisation side — module workers need Firefox 114+, transferable OffscreenCanvas needs Firefox 105+, and shared memory needs both modern engines and correctly configured isolation headers. Build the reshape-and-transfer path as the baseline, since it is supported by every browser in the table, and treat shared memory as a progressive enhancement gated on crossOriginIsolated.

Support tiers for the APIs this pipeline uses Three ascending steps. Tier one is universal: postMessage with structured clone, transferable ArrayBuffer and performance.now() inside workers work in every browser in the compatibility table. Tier two is version-gated and needs feature detection: the structuredClone global, module workers, User Timing inside workers and the messageerror event. Tier three is isolation-gated: SharedArrayBuffer, Atomics.waitAsync and transferable OffscreenCanvas, which additionally require cross-origin isolation via COOP and COEP headers and are undefined without them. What this pipeline needs, by support tier Cr = Chrome · FF = Firefox · Sf = Safari Tier 1 — universal postMessage + structured clone transferable ArrayBuffer · performance.now() supported by every browser in the table Tier 2 — version-gated structuredClone(): Cr 98 · FF 94 · Sf 15.4 module workers: Cr 80 · FF 114 · Sf 15 User Timing in workers: Cr 45 · FF 41 messageerror: Cr 60 · FF 57 · Sf 12.1 feature-detect, then fall back Tier 3 — isolation-gated SharedArrayBuffer: Cr 92 · FF 79 · Sf 15.2 Atomics.waitAsync: Cr 87 · FF 132 · Sf 16.4 OffscreenCanvas transfer: FF 105 · Sf 16.4 needs COOP: same-origin plus COEP: require-corp undefined without cross-origin isolation gate on crossOriginIsolated === true Build tier 1 as the path that always works, and gate tiers 2 and 3 on feature detection.
The measurement techniques sit entirely in tier 1; every compatibility cliff on this page belongs to the optimisation, which is why the reshape-and-transfer path makes the safest baseline.

Frequently Asked Questions

How do I measure the actual cost of structured cloning in a postMessage call?
Two measurements, not one. For the sender-side serialization cost, record performance.now() immediately before and after worker.postMessage(payload) — in V8 the serialize step runs synchronously inside that call, so the gap is real main-thread blocking time. For the total clone cost (serialize plus deserialize) without a thread hop, call structuredClone(payload) and time that instead. Compare the two: the difference is roughly what the receiving thread pays on delivery. See Measuring Structured Clone Cost with performance.now() for a minimal reproducible harness.
At what payload size does structured cloning become a problem?
Bytes are the wrong unit — object count is the right one. A 1 MB Float32Array clones in well under a millisecond because it is essentially a memcpy with a header. A 1 MB array of 20,000 plain objects with a dozen fields each costs an order of magnitude more, because every object, key and string is visited, hashed for cycle detection and re-allocated on the target heap. Reach for transferable ArrayBuffer when a single message costs more than about 2 ms, which is where it starts eating a meaningful slice of a 16.7 ms frame.
What is the maximum safe postMessage frequency?
There is no hard browser limit — the queue grows until you run out of memory. The practical ceiling is set by cost per message multiplied by rate. At 60 messages per second, a 4 ms serialize cost consumes 240 ms of every second of main-thread time, roughly a quarter of your frame budget gone before any rendering happens. Cap the send rate with a token bucket and coalesce pending updates into one message per animation frame.
Does SharedArrayBuffer eliminate postMessage overhead entirely?
For the data path, yes: both threads read the same memory, so there is no copy and no serialization. You still need one signal per batch — either a tiny postMessage or Atomics.notify() on a control word — and you take on the cost of correct synchronization plus the COOP/COEP cross-origin isolation requirement. See postMessage vs SharedArrayBuffer: When to Choose Each for the decision criteria.
Why does my worker report a negative or nonsensical latency when I subtract timestamps across threads?
Each worker gets its own performance.timeOrigin, set when the worker global scope is created — it is not the document’s origin. Raw performance.now() values are therefore not comparable across threads. Convert both sides to absolute epoch milliseconds with performance.timeOrigin + performance.now() before subtracting, or ship the sender’s timeOrigin in the first message and correct for the delta.

See also