npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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

Readme

@zakkster/lite-worker-profiler

postMessage serialization cost, made visible.

npm version Zero-GC sponsor npm bundle size npm downloads npm total downloads TypeScript Dependencies License: MIT

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-profiler

Message 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:

  1. Run structuredClone(data) to measure the actual clone cost (same algorithm the browser uses internally).
  2. Time the postMessage() call itself (clone + internal posting overhead).
  3. Estimate payload bytes via estimateBytes() (ArrayBuffer.byteLength for typed data, JSON heuristic for objects).
  4. 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 postMessage yourself 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:

  1. Main writes its performance.now() and sets a flag via Atomics.store.
  2. Worker wakes via Atomics.wait, reads its own performance.now(), writes it, signals done.
  3. Main computes offset: (mainT0 + mainT1) / 2 - workerT (assumes symmetric latency).
  4. Repeat N rounds. syncClock returns the round with the median RTT -- both offsetMs and rttMs come 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 Float32Array embedded 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 so JSON.stringify doesn't explode a nested Float32Array into 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