@zakkster/lite-worker-profiler
v1.0.0
Published
Structured-clone cost measurement and SharedArrayBuffer-based cross-thread timeline alignment. postMessage serialization cost made visible.
Downloads
79
Maintainers
Readme
@zakkster/lite-worker-profiler
postMessage serialization cost, made visible.
Structured-clone cost measurement and SharedArrayBuffer-based cross-thread timeline alignment. Two independent primitives: message profiling (what does serialization cost?) and clock sync (how do I align traces across threads?).
npm install @zakkster/lite-worker-profilerMessage profiling
import { createMessageProfiler } from '@zakkster/lite-worker-profiler';
const profiler = createMessageProfiler({
onMessage(m) {
console.log('clone:', m.cloneMs.toFixed(2), 'ms',
'post:', m.postMs.toFixed(2), 'ms',
'bytes:', m.bytes);
}
});
// Wrap the worker -- every postMessage is now measured
profiler.wrap(worker);
worker.postMessage(bigPayload);
// Or measure without sending
const cost = profiler.measure(bigPayload);
console.log('clone cost:', cost.cloneMs, 'ms');
// Aggregate
console.table(profiler.summary());How it works
wrap() patches postMessage to:
- Run
structuredClone(data)to measure the actual clone cost (same algorithm the browser uses internally). - Time the
postMessage()call itself (clone + internal posting overhead). - Estimate payload bytes via
estimateBytes()(ArrayBuffer.byteLength for typed data, JSON heuristic for objects). - Record all three into a preallocated ring buffer (
Float64Array, power-of-two capacity).
The onMessage callback receives a reusable object (no allocation per call).
Overhead
wrap() clones the payload twice per send: once explicitly (to measure cloneMs) and once inside the real postMessage() (which is what postMs measures). A wrapped postMessage is therefore about 2x slower than an unwrapped one when the payload is large. This is the price of separating the two numbers.
For steady-state profiling of a hot messaging path:
- Call
measure(data)on a representative payload once to characterise clone cost, then let messages flow through the un-wrapped worker in production. - Or wrap the worker only during a specific dev session and unwrap it afterward.
- Or sample selectively (patch
postMessageyourself with your own gating).
measure() clones once; wrap() clones twice. Pick the tool that matches how much overhead you can tolerate.
Clock sync
Workers and the main thread have independent performance.now() clocks. To align traces from different threads on a single Perfetto timeline, you need a shared time reference.
// Main thread
import { createSyncBuffer, syncClock } from '@zakkster/lite-worker-profiler';
const sab = createSyncBuffer();
worker.postMessage({ type: 'clock-sync', sab });
const { offsetMs, rttMs, rounds } = await syncClock(sab, { rounds: 5 });
console.log('offset:', offsetMs.toFixed(3), 'ms (rtt:', rttMs.toFixed(3), 'ms)');
// workerTime = mainTime - offsetMs
// Worker
import { workerSyncClock } from '@zakkster/lite-worker-profiler';
self.onmessage = (e) => {
if (e.data.type === 'clock-sync') {
workerSyncClock(e.data.sab, { rounds: 5 });
}
};Protocol
NTP-style exchange over a 40-byte SharedArrayBuffer:
- Main writes its
performance.now()and sets a flag viaAtomics.store. - Worker wakes via
Atomics.wait, reads its ownperformance.now(), writes it, signals done. - Main computes offset:
(mainT0 + mainT1) / 2 - workerT(assumes symmetric latency). - Repeat N rounds.
syncClockreturns the round with the median RTT -- bothoffsetMsandrttMscome from the same sample, so the caller can reason about their correlation.
Wake-latency caveat. The NTP formula assumes the message takes the same time to travel main -> worker as worker -> main. In practice, waking an idle worker is fast, but waking the main thread while it's busy rendering, running rAF callbacks, or handling DOM events takes longer, biasing mainT1 upward and skewing offsetMs away from zero. The median-RTT selection reduces this because the lowest-RTT rounds tend to have the least asymmetry, but you'll get the cleanest reading by calling syncClock during a quiescent period (right after page load, or between animation frames when the main thread is idle) rather than while your app is doing heavy work.
API
createMessageProfiler(options?)
| Option | Default | Description |
|--------|---------|-------------|
| capacity | 256 | Ring buffer size |
| estimateSize | true | Estimate payload bytes |
| onMessage | null | Callback per message |
Returns: { measure, wrap, getMessages, summary, reset, count }
Both measure() and wrap() silently catch structured-clone failures (functions, DOM nodes) and report cloneMs: 0 -- a diagnostic tool that throws during profiling would mask the actual bug the user is investigating.
syncClock(sab, options?)
Main-thread side. Returns Promise<{ offsetMs, rttMs, rounds }>.
workerSyncClock(sab, options?)
Worker-thread side. Blocks on Atomics.wait (safe in workers).
createSyncBuffer()
Allocate the 40-byte SharedArrayBuffer.
readOffset(sab)
Read the computed offset from the SAB after sync completes.
estimateBytes(data)
Heuristic size estimation:
- Exact for top-level
ArrayBuffer/TypedArray/DataView/Blob. - For objects/arrays: walks the graph once to sum any nested binary payloads (a
Float32Arrayembedded in a mesh object, for example), then adds a JSON-based estimate for the non-binary parts. The JSON pass uses a replacer that stubs binary payloads soJSON.stringifydoesn't explode a nestedFloat32Arrayinto a{"0":0,"1":0,...}dictionary and grossly overestimate. - Tolerates cycles via a
WeakSet. - Non-serializable input (functions, DOM nodes) falls back to 256 bytes.
Cold-path helper -- don't call per frame.
License
MIT (c) Zahary Shinikchiev
