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.
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:
crossOriginIsolatedmust betruebeforeSharedArrayBuffereven 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.
Map is visible at a glance.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.
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.
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.
- Open Performance, click the settings gear, and make sure worker threads are captured (each dedicated worker gets its own track under the main frame).
- Trigger the known-bad scenario and record 5–10 seconds. Do not record the very first interaction — capture a warmed-up steady state.
- 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.
- 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.
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
Uint16Arrayof indices into a dictionary sent once at handshake time, not a string per row. MapandSetare graph structures. They clone entry by entry. AMapof 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.
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.
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.
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.
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.
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.
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:
- Is the payload a control message under a few kilobytes? Clone it. The analysis is not worth your time.
- 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.
- 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.
- 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.
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.
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.
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.
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.