Profiling Worker CPU Usage with the Chrome Performance Tab

A worker that takes 14 ms to answer is not necessarily doing 14 ms of work — and the Performance panel will tell you exactly how much of it was your algorithm, if you record it correctly.

This page narrows postMessage Bottleneck Analysis, part of the Debugging, Profiling & Production Optimization reference, to one narrow task: reading a dedicated worker’s own track in Chrome DevTools and attributing every millisecond in it to either compute or message overhead. The companion page Measuring Structured Clone Cost with performance.now() puts numbers on that overhead from code; this one finds it in a trace.

What a Worker’s Track Actually Contains

Offloading work to a worker removes it from the main thread’s frame budget, but it does not make the work free, and it adds a cost that did not exist before: the payload has to be copied across the thread boundary in both directions. In a trace, all three of those costs land inside a single task on the worker’s track, in this order:

  1. Deserialize. Before your handler body runs, the serialized payload has to become a live object graph on the worker’s heap. In Blink this is lazy — it happens on the first read of event.data — so it usually appears as an unattributed block at the top of the message-event task, right where a reader instinctively assumes their own code has started.
  2. Compute. Your actual algorithm. This is the only part that a faster algorithm can shrink.
  3. Serialize. The reply passed to self.postMessage() is serialized synchronously inside that call, so it is still part of the same task even though it looks like the task has ended.

Because all three are one contiguous task, the naive reading — “the worker task is 14 ms, so the algorithm is 14 ms” — is wrong by however much the payload costs, and for a large object graph that can be most of it.

What one message-event task on a worker's track actually contains A single 14 millisecond task on the worker track, drawn to scale and split into three adjacent segments. The first segment, 7.6 milliseconds wide, is deserialization: the incoming payload becoming a live object graph on the first read of event.data. The middle segment, 4.1 milliseconds, is the algorithm itself, and a user-timing band from performance.measure sits directly beneath it covering exactly that region. The last segment, 2.3 milliseconds, is serialization of the reply inside postMessage. Subtracting the band from the task leaves 9.9 milliseconds, or 71 percent of the task width, spent copying rather than computing. One message-event task, drawn to scale time → what the task width reports — 14.0 ms Worker track filter.worker.js one message-event task — 14.0 ms Deserialize the incoming payload becomes a live object graph 7.6 ms Compute your algorithm runs here 4.1 ms Serialize reply, inside postMessage() 2.3 ms user timing performance.measure() 4.1 ms — compute only task − band = 9.9 ms of copying: 71% of the width, before your loop runs an instruction Only the middle segment responds to a better algorithm; the two ends respond only to a smaller or transferred payload.
The three costs are contiguous inside one task, so the task's width is an upper bound on your algorithm — never a measurement of it.

Minimal Reproducible Example

The smallest instrumentation that makes the split visible is a pair of marks around the computation and a measure joining them. Everything else in the snippet exists to keep a long-lived worker from leaking entries:

// filter.worker.js — classic or module worker, both behave identically here
self.onmessage = (event) => {
  // Reading event.data forces deserialization; do it once, deliberately,
  // BEFORE the start mark so the mark does not straddle the copy.
  const { taskId, samples, threshold } = event.data;

  const start = `${taskId}:start`;
  const end = `${taskId}:end`;
  const span = `${taskId}:compute`;

  try {
    performance.mark(start);
    const result = filterAboveThreshold(samples, threshold);
    performance.mark(end);
    performance.measure(span, start, end);

    const [entry] = performance.getEntriesByName(span, 'measure');
    self.postMessage(
      { taskId, result, computeMs: entry ? entry.duration : 0 },
      [result.buffer]                   // transfer the reply, do not clone it
    );
  } finally {
    // A worker that lives for hours accumulates entries forever otherwise.
    performance.clearMarks(start);
    performance.clearMarks(end);
    performance.clearMeasures(span);
  }
};

function filterAboveThreshold(samples, threshold) {
  const input = new Float64Array(samples);
  const out = new Float64Array(input.length);
  let n = 0;
  for (let i = 0; i < input.length; i++) {
    if (input[i] > threshold) out[n++] = input[i];
  }
  return out.subarray(0, n);
}

The typed call site on the main thread, for a worker pool where several threads share one script:

interface FilterRequest {
  taskId: string;
  samples: ArrayBuffer;   // transferred, so the sender pays no copy
  threshold: number;
}

const worker = new Worker(new URL('./filter.worker.js', import.meta.url), {
  type: 'module',
  name: 'filter-0',       // shows up as self.name inside the worker
});

function send(samples: ArrayBuffer, threshold: number): void {
  const req: FilterRequest = { taskId: `filter-0/${crypto.randomUUID()}`, samples, threshold };
  worker.postMessage(req, [samples]);
}

Step-by-Step Walkthrough

const { taskId, samples, threshold } = event.data;

This destructuring is the deserialize step, not a free property read. Placing it above the start mark is deliberate: if the first touch of event.data happened inside the measured region, the band would include the copy and the whole exercise would measure nothing useful. Put the read before the mark and the band contains compute only, with the copy visible as the gap between the task’s left edge and the band’s left edge.

const start = `${taskId}:start`;

Mark names are per-global and unscoped, so a pool of eight workers running the same script produces eight streams of identically named entries in eight separate tracks. Prefixing with a task id — and, in a pool, with the worker’s name — means a band tells you which thread and which request it belongs to without cross-referencing anything. This matters because worker tracks are labelled by script URL, and eight workers from one script give you eight tracks with the same label.

performance.measure(span, start, end);

The measure, not the marks, is what you read in the timeline: marks render as instantaneous ticks, measures as bands with a width. Only the band lets you compare compute against the enclosing task by eye.

const [entry] = performance.getEntriesByName(span, 'measure');
self.postMessage({ taskId, result, computeMs: entry.duration }, [result.buffer]);

Shipping computeMs back with the result gives you the same number without DevTools open — useful for a production histogram, and useful as a cross-check that what you are reading in the flame chart is the region you think it is. The second argument transfers the result buffer, so the serialize suffix of this task stays near zero instead of scaling with the output size; that is the same ownership handoff described in Transferable Objects & Zero-Copy.

} finally {
  performance.clearMarks(start);

The user-timing buffer has no automatic eviction for marks and measures. A worker handling 50 requests per second accumulates 150 entries per second indefinitely; over an hour that is half a million objects that will never be collected and will slow every subsequent getEntriesByName call, because the lookup walks the buffer.

Capturing a Trace That Contains the Worker

Capturing a trace that actually contains the worker Three numbered panels connected left to right by arrows. Panel one shows the Performance panel toolbar with the plain record button dimmed and the reload-and-profile control highlighted, because a worker constructed before recording started may never get a track. Panel two shows track configuration: the main thread and the raster, GPU and compositor tracks are unchecked, one track labelled filter.worker.js is checked and highlighted, and a second identically labelled worker track is left hidden. Panel three shows a single message-event task zoomed in, with the user-timing band drawn beneath the compute segment so the deserialize prefix and serialize suffix can be read straight off the band's edges. 1 2 3 Reload and profile Isolate the worker Zoom to one task reload + profile restarts the page Record the reload, not the run. A worker constructed before the recorder attached may never get a track at all. Stop as soon as the reply lands. Idle timeline squeezes the 14 ms. track configuration Main — main thread Raster · GPU · Compositor filter.worker.js shown filter.worker.js (worker 2) Eight pool threads, eight identical labels. zoom to one task band The band's edges give you the prefix and suffix directly: before it = deserialize, after it = serialize. Then select the band's range and read Bottom-Up by Self Time — restricting the range first is what keeps deserialization frames out of the ranking.
Each step removes one class of noise: the reload guarantees the track exists, track configuration gives it the vertical space, the zoom makes the band's edges legible.
  1. Open the Performance panel and record with the reload-and-profile control rather than the plain record button. A worker constructed during page startup may never get a track if the recorder attaches after construction; reloading under the recorder guarantees the thread’s creation is inside the trace.
  2. Trigger the workload, then stop as soon as the reply lands. Ten seconds of idle timeline makes the interesting 14 ms unreadably narrow.
  3. Find the track group labelled with the worker’s script URL. Collapse the main thread and the rendering tracks — or hide them entirely through the timeline’s track configuration — so the worker occupies the vertical space.
  4. Zoom to a single message-event task. The user-timing band from your performance.measure() renders beneath the stack frames for that thread; align its edges against the task’s edges to read the overhead prefix and suffix directly.
  5. Select the range covered by the band and switch the call tree to Bottom-Up, sorted by Self Time. Restricting the selection first is what keeps deserialization frames out of the ranking.
Structured-clone cost is often the real bottleneck

A 10 MB flat typed array costs roughly 7 ms to serialize and a comparable amount to deserialize; a 10 MB dense object graph can cost ten times that. Against a 16.7 ms frame budget, one such message is a dropped frame before your algorithm has executed a single instruction. Profile the same workload once as a clone and once as a transferable ArrayBuffer — if the band stays the same width while the task shrinks, the payload was the bottleneck.

Compute Time Versus Transfer Strategy

The reason to split the task is that the two halves respond to completely different fixes. Optimising a loop does nothing for a serialization-dominated task, and switching to transfers does nothing for a genuinely CPU-bound one.

Property Structured clone (objects, maps, nested graphs) Transfer (ArrayBuffer, MessagePort, ImageBitmap)
Cost in the trace Visible prefix before the band and suffix after it, scaling with payload size and shape Prefix and suffix collapse to a near-constant fraction of a millisecond
Sender blocking Sender’s thread blocked for the whole serialize step Pointer handoff; sender resumes immediately
Peak memory Roughly 2× the payload while both copies exist Flat — ownership moves, no second copy
Correct use Small control messages and configuration, under ~100 KB Bulk numeric data, image buffers, audio frames
The same 5 MB payload profiled as a clone and as a transfer Two worker tasks drawn on one shared millisecond axis. Task A sends a 5 MB object graph by structured clone: it runs 25.5 ms, made of 12.0 ms deserialize, 4.1 ms compute and 9.4 ms serialize, so it overruns the 16.7 ms frame budget by 8.8 ms and only 16 percent of its width sits inside the user-timing band. Task B sends the same 5 MB as a transferred ArrayBuffer: the deserialize and serialize segments collapse to hairlines of 0.2 and 0.1 ms, the task runs 4.4 ms, and 93 percent of it sits inside the band. The compute band is exactly the same width in both rows, because the algorithm never changed. Same payload, same algorithm — clone versus transfer, at one scale 16.7 ms frame budget task A overruns it by 8.8 ms A — 5 MB object graph, structured clone task 25.5 ms · band 4.1 ms (16%) deserialize 12.0 ms compute 4.1 ms serialize reply 9.4 ms band 4.1 ms 21.4 ms outside it — 84% of the task is copying B — same 5 MB, transferred as an ArrayBuffer task 4.4 ms · band 4.1 ms (93%) compute 4.1 ms The prefix and suffix collapse to hairlines 0.2 ms + 0.1 ms: ownership moves, nothing is copied. Peak memory stays flat instead of doubling. band 4.1 ms Identical band — the algorithm never changed. ms → 0 4 8 12 16 20 24 28
The band is the control: it stays the same width in both rows, so everything that moved between them was payload cost, not algorithm cost.

Reading the Flame Chart Once the Band Is in Place

With the compute region delimited, the shapes inside it become meaningful:

  • Wide, flat blocks are a single synchronous function holding the thread. Trace it in Bottom-Up by Self Time; this is the one shape a better algorithm reliably fixes.
  • Long runs of narrow, repeated blocks are per-item call overhead — a callback invoked once per element, or an allocation inside a hot loop. Hoisting the allocation out of the loop typically collapses these more than micro-optimising the body does.
  • A deep, stable stack that never widens is usually fine. Depth costs almost nothing; width is time.
  • Gaps inside the band mean the worker yielded — a setTimeout(…, 0) chunk boundary, or an await. That is intentional in cancellable jobs but it inflates the measure’s duration beyond actual CPU time, so read computeMs as wall clock, not as CPU, whenever the region contains an await point.
  • Cross-thread correlation. Line the worker’s task up against the main thread’s frames in the same trace. If main-thread frames still drop while the worker is busy, the offload is not the fix you thought it was — the jank is coming from the messages, not the work.

Gotchas & Edge Cases

Sub-millisecond functions can vanish from the flame chart

The flame chart for JavaScript is built from a sampling profiler running on the order of one sample per millisecond, so a function that takes 0.3 ms may be sampled once, or not at all, and its apparent width will move between recordings. User-timing bands come from explicit instrumentation and do not have this problem. Below about 2 ms, trust the band and the duration value you posted back; treat the flame chart as a map of where to put the next mark, not as a measurement.

The two threads’ clocks do not share an origin

performance.now() in a worker counts from the moment its global scope was created, not from navigation start, so a worker spawned eight seconds into a page’s life reports timestamps about 8000 ms behind the document’s. Subtracting a worker timestamp from a main-thread one to get message latency produces a plausible-looking, badly wrong number. Convert both to performance.timeOrigin + performance.now() before subtracting — the clock-alignment procedure is worked through in full on Measuring Structured Clone Cost with performance.now(). DevTools itself does this alignment for you when it draws the tracks, which is why a trace can look consistent while your own arithmetic does not.

Blob and inline workers produce unreadable track labels

A worker created from URL.createObjectURL(new Blob([src])) gets a track labelled with an opaque blob: URL that changes on every reload, and a pool of them is indistinguishable. Pass a name to the constructor, echo self.name into your measure names, and the bands become self-identifying regardless of what the track label says. If you need file-level attribution too, append a //# sourceURL=filter.worker.js comment to the generated source.

Timer resolution is coarse unless the page is cross-origin isolated

Outside a cross-origin-isolated context, performance.now() is quantized to 100 µs as a Spectre mitigation, so a 0.3 ms compute band carries roughly ±20% error and two candidate implementations differing by 50 µs are indistinguishable. Serving the profiling build with Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp restores 5 µs resolution — the same isolation SharedArrayBuffer requires. Those headers also block third-party frames and images that do not opt in via CORS or CORP, so apply them to a dedicated profiling page rather than the whole application.

A measure that spans a yield is not CPU time

If the compute region contains await, setTimeout, or a nested postMessage round trip, the measure’s duration includes time the worker spent idle. Either place the marks inside each synchronous chunk and sum the durations, or accept the number as end-to-end latency and label it as such.

Performance Note

The number worth carrying out of a profiling session is the ratio of the user-timing band to its enclosing task, not the task duration. On a mid-range laptop, a worker task that spends more than about 30% of its width outside the band is dominated by message overhead, and the highest-value change is the transfer strategy rather than the algorithm — roughly 0.7 ms per megabyte for flat typed arrays, and ten to thirty times that for dense object graphs, in each direction. Below that threshold the algorithm is genuinely the cost, and the Bottom-Up view sorted by Self Time inside the band tells you exactly which frame to attack.

Frequently Asked Questions

How do I tell whether a spike in the worker's flame chart is compute or serialization overhead?
Bracket the computation with performance.mark() on both sides and join the marks with performance.measure(). The measure renders as a named band inside the worker’s track, and everything that sits inside the worker’s message-event task but outside that band is overhead — deserialization of the incoming payload on entry, serialization of the reply on exit. If the band covers 2 ms of a 14 ms task, your algorithm is not the problem; the payload is. Re-send the same bytes as a transferable ArrayBuffer and the task should shrink to roughly the width of the band.
Why does my worker have no track in the Performance panel?
Three causes, in order of likelihood. (1) The worker was constructed before the recording started, so the profiler never saw its creation — record with the reload-and-profile control instead of the plain record button, so the page restarts under the recorder. (2) The worker terminated before you stopped the recording; a track only survives if the thread was alive for part of the trace. (3) The track exists but is hidden or collapsed — open track configuration from the timeline’s track controls and re-enable it. Worker tracks are labelled with the worker’s script URL, so a blob: worker shows an opaque label rather than a filename.

See also