Debugging, Profiling & Production Optimization
Architectural reference for diagnosing, measuring, and scaling isolated JavaScript execution. Everything on this page addresses one structural fact: a worker is a separate realm with its own heap, its own event loop, and no shared reference to anything the main thread holds. That isolation buys you a responsive UI and costs you observability — the tools you would normally reach for (a breakpoint, a heap snapshot, a console.log, an error handler) all default to the wrong context. This reference is for frontend engineers who already run work off the main thread and now need those pipelines observable, recoverable, and memory-safe in production, alongside the sibling references on Web Workers Architecture & Communication and High-Performance Computation Patterns in this JavaScript Web Workers reference.
Strict thread isolation mandates explicit state transfer protocols. Deterministic profiling requires decoupled main-thread instrumentation. Production optimization hinges on serialization cost reduction and fault-tolerant lifecycle management. The five areas covered here — DevTools inspection, error recovery, memory leak isolation, postMessage throughput, and production telemetry — map directly to the five topics linked throughout this page.
The Observability Model: What Each Context Can Actually See
Before choosing a tool, decide which realm holds the evidence. Almost every wasted hour of worker debugging comes from looking for a signal in a context that structurally cannot produce it — searching window.onerror logs for an exception that was raised inside a worker, or reading a main-thread heap snapshot that does not contain a single byte of the worker’s heap.
| Observation surface | Main thread | Dedicated worker |
|---|---|---|
DOM, layout, document |
Available | Not available — no document, no window |
console.* output |
Default Console context | Only after switching the Console context selector to the worker |
| Breakpoints, stepping | Default Sources target | Separate debug target listed under the Threads pane |
| Heap snapshot | Default JS VM instance | Separate JS VM instance, chosen explicitly in the Memory panel |
| Uncaught exception | window error event |
ErrorEvent on the Worker handle and self.onerror inside |
| Unhandled rejection | window unhandledrejection |
Worker-scope unhandledrejection only — it never bubbles |
performance marks |
Main-thread timeline | Independent performance object with its own mark buffer |
| Out-of-memory kill | Tab crash, visible | Silent termination — no event of any kind fires |
The last row is the one that costs teams the most: when a worker exceeds the engine’s heap ceiling, the browser destroys it without dispatching error. From the main thread the symptom is indistinguishable from a worker stuck in an infinite loop — messages go out, nothing comes back. Only an explicit liveness signal separates the two cases.
postMessage crosses — which is why a probe aimed at the wrong realm returns nothing rather than an answer.Symptom to first probe
Use this table as the entry point to the rest of the reference. Each row names the cheapest measurement that confirms or eliminates the hypothesis.
| Symptom | Most likely cause | First probe | Covered in |
|---|---|---|---|
UI stalls the instant you call postMessage |
Structured clone of a large object graph, paid on the calling thread | Performance recording, look for the serialize/clone block before the message dispatch | postMessage Bottleneck Analysis |
| Worker goes quiet, no error anywhere | OOM kill, or a synchronous loop that never yields | Heartbeat message every 1 s plus heap sampling | Error Handling & Crash Recovery |
| Heap grows every workload cycle and never returns | Retained closures, module-level caches, or un-nulled transferred buffers | Two heap snapshots either side of a full cycle, Comparison view | Identifying Memory Leaks in Workers |
DataCloneError when sending |
Payload contains a function, a DOM node, or a class instance that is not cloneable | Log the payload’s shape before dispatch, narrow by bisecting fields | Chrome DevTools Worker Debugging |
messageerror fires on the receiving side |
The value could not be deserialized in the target realm | Inspect the event’s data; check for realm-specific types |
Structured Error Serialization Across Threads |
SharedArrayBuffer is not defined |
Document is not cross-origin isolated | Evaluate crossOriginIsolated in the Console |
Debugging SharedArrayBuffer Cross-Origin Errors |
| Errors reproduce locally but never reach the dashboard | No error forwarding from the worker realm | Confirm self.onerror is registered in the shipped bundle |
Production Error Telemetry |
Core Mechanics: Lifecycle, Readiness, and the Instrumentation Seam
Worker pool initialization trades memory overhead for reduced cold-start latency. Spawning a module worker costs roughly 1–3 ms of process-side setup in Chrome plus however long the worker’s script takes to fetch, compile, and evaluate — for a bundle with a WebAssembly import that can easily reach 30–80 ms. On-demand instantiation conserves heap space but pays that cost inside the user’s interaction. Pre-warming pays it during idle time instead. Explicit termination protocols prevent zombie thread accumulation, and state synchronization relies on immutable message passing or shared memory buffers.
Understanding the Web Workers Architecture & Communication patterns is a prerequisite for effective debugging — thread-boundary violations and message-passing misconfigurations are the root cause of the majority of worker performance problems. In particular, the Main Thread vs Worker Thread Lifecycle contract defines when a worker is legally allowed to receive work, and Worker Pool Management defines how many should exist at once.
Three lifecycle facts drive nearly every diagnostic decision that follows:
terminate()is immediate and unconditional. Pending messages are dropped, in-flight promises never settle, and no cleanup handler runs inside the worker. Anything you need flushed must be flushed before you terminate — see the drain protocol below.- A worker is not ready when the constructor returns.
new Worker(url)resolves synchronously, but the script has not evaluated yet. Messages posted before evaluation are queued, which is safe, but it means the first task’s latency silently includes compile time unless you handshake. - There is exactly one seam where you can observe everything. Every task crosses the boundary through
postMessage, so a single dispatch chokepoint on the main thread — plus its mirror inside the worker — is enough to time, tag, and correlate 100% of the traffic. Instrument that seam once and you never need ad-hoc logging again.
The pool below implements that seam. Read it as the reference shape for the rest of this page: every worker carries an identity, every task carries a correlation id, and idle workers are reaped deterministically rather than left to accumulate.
// main-thread.ts
export interface WorkerPoolConfig {
maxWorkers: number;
idleTimeoutMs: number;
scriptURL: string;
}
export class DeterministicWorkerPool {
// Identity per worker is what makes traces readable: without a stable id you
// cannot tell which of eight identical threads produced a slow measurement.
private workers: Map<string, { instance: Worker; lastActive: number; busy: boolean }> = new Map();
private taskQueue: Array<{
id: string;
payload: unknown;
resolve: (v: unknown) => void;
reject: (e: Error) => void;
}> = [];
private config: WorkerPoolConfig;
private idleTimer: ReturnType<typeof setInterval>;
constructor(config: WorkerPoolConfig) {
this.config = config;
// Reaping on an interval (rather than per-task) keeps teardown observable:
// worker count becomes a step function you can plot against heap usage.
this.idleTimer = setInterval(() => this.reapIdleWorkers(), 1000);
}
async dispatch<T>(taskId: string, payload: unknown): Promise<T> {
return new Promise<T>((resolve, reject) => {
const worker = this.acquireWorker();
if (!worker) {
// Backlog rather than spawn: unbounded spawning is the classic way to
// turn a slow worker into an out-of-memory kill.
this.taskQueue.push({ id: taskId, payload, resolve: resolve as (v: unknown) => void, reject });
return;
}
this.processQueue(worker);
});
}
private acquireWorker(): Worker | null {
for (const [, meta] of this.workers) {
if (!meta.busy) {
meta.busy = true;
meta.lastActive = Date.now();
return meta.instance;
}
}
if (this.workers.size < this.config.maxWorkers) {
return this.spawnWorker();
}
return null;
}
private spawnWorker(): Worker {
const id = crypto.randomUUID();
const worker = new Worker(this.config.scriptURL, { type: 'module' });
worker.onmessage = (e) => this.handleMessage(id, e.data);
worker.onerror = (e) => this.handleError(id, e);
this.workers.set(id, { instance: worker, lastActive: Date.now(), busy: true });
return worker;
}
private handleMessage(workerId: string, data: unknown) {
const meta = this.workers.get(workerId);
if (!meta) return;
meta.busy = false;
meta.lastActive = Date.now();
const task = this.taskQueue.shift();
if (task) {
task.resolve((data as { result: unknown }).result);
this.processQueue(meta.instance);
}
}
private handleError(workerId: string, err: ErrorEvent) {
const meta = this.workers.get(workerId);
if (!meta) return;
// A worker that threw is not trustworthy for the next task: its module
// scope may hold half-mutated state. Production pools replace it here.
meta.busy = false;
const task = this.taskQueue.shift();
if (task) task.reject(new Error(err.message));
}
private processQueue(worker: Worker) {
const task = this.taskQueue.shift();
if (task) {
worker.postMessage({ id: task.id, payload: task.payload });
}
}
private reapIdleWorkers() {
const now = Date.now();
for (const [id, meta] of this.workers) {
if (!meta.busy && now - meta.lastActive > this.config.idleTimeoutMs) {
meta.instance.terminate();
this.workers.delete(id);
}
}
}
public destroy() {
clearInterval(this.idleTimer);
for (const [, meta] of this.workers) {
meta.instance.terminate();
}
this.workers.clear();
// Settling outstanding promises on teardown prevents the "hung await"
// class of bug in single-page apps that dispose pools on route change.
this.taskQueue.forEach(t => t.reject(new Error('Pool destroyed')));
this.taskQueue = [];
}
}
The single most valuable line in that class is crypto.randomUUID(). Correlation ids — one per worker, one per task — turn an unreadable interleaved log into a join key. Once every message carries { workerId, taskId, t0 }, you can compute queue wait, transfer time, and compute time separately, which is the difference between “the worker is slow” and “the worker waits 40 ms for a free slot and then computes for 4 ms”. Teams building on top of this typically formalise it as a task envelope; the trade-offs of fixed versus elastic pools are covered in Dynamic vs Fixed-Size Worker Pools.
t0 at dispatch and t2 at the reply — with the worker's own phase measures riding along — separates queue wait, transfer and compute without any ad-hoc logging.Diagnostic Tooling & Runtime Inspection
Background thread execution requires decoupled inspection strategies. Main-thread profiling tools cannot directly observe isolated contexts. The Chrome DevTools Sources panel exposes a Threads pane that lists every active worker context, letting you attach a debugger to each independently, pause one thread while others keep running, and step through worker code with full scope inspection. The Console has a matching context selector: until you switch it to the worker, every expression you evaluate runs in the page realm, where the worker’s module scope simply does not exist.
Chrome DevTools Worker Debugging covers the full workflow: enabling breakpoint isolation, tracing structured clone overhead in the Performance panel, capturing heap snapshots from the worker’s own memory context, and validating COOP/COEP headers for SharedArrayBuffer usage. For teams that develop primarily in Firefox, Firefox Worker Debugging documents the equivalent workflow in the Firefox DevTools debugger, and Inspecting Worker Scopes in Firefox DevTools walks through reading a paused worker’s scope chain. If your team splits across both engines, Comparing Chrome and Firefox Worker Tooling maps each capability across the two.
DevTools alone is not enough, because the interesting failures happen on machines you cannot attach to. Custom performance.mark() calls emitted via postMessage provide deterministic telemetry without UI-thread interference, and the same instrumentation runs unchanged in production behind a sampling flag. Three rules keep that instrumentation honest:
- Mark inside the realm you are measuring. The worker’s
performanceobject has its own mark buffer and, critically, itstimeOriginmay differ from the page’s. Send absoluteperformance.now()values across the boundary only if you also sendperformance.timeOrigin, otherwise compute durations inside the worker and send those. - Never log inside a hot loop. A
console.logper iteration serialises the argument for the DevTools protocol; on a million-iteration loop it can dominate the profile and change the very thing you are measuring. - Batch the measurements out. One
postMessageper mark reintroduces the overhead you are trying to quantify. Buffer measures inside the worker and flush them on task completion.
// worker.ts — instrumentation seam inside the worker realm
interface TaskEnvelope { id: string; payload: unknown }
interface Measure { taskId: string; phase: string; ms: number }
const measures: Measure[] = [];
/** Times one phase and records the duration without crossing the boundary. */
function timed<T>(taskId: string, phase: string, fn: () => T): T {
const start = performance.now();
try {
return fn();
} finally {
// performance.now() inside a worker is relative to THIS realm's timeOrigin,
// so only the delta is meaningful on the other side.
measures.push({ taskId, phase, ms: performance.now() - start });
}
}
self.onmessage = (event: MessageEvent<TaskEnvelope>) => {
const { id, payload } = event.data;
try {
const parsed = timed(id, 'decode', () => decodeTask(payload));
const result = timed(id, 'compute', () => runComputation(parsed));
// Flush the whole measure buffer once, attached to the reply, so the
// instrumentation costs exactly one extra message per task.
self.postMessage({ id, result, measures: measures.splice(0) });
} catch (error) {
// Error instances do not reliably carry `stack` through structured clone,
// so flatten to a plain object before it crosses the boundary.
const e = error as Error;
self.postMessage({
id,
error: { name: e.name, message: e.message, stack: e.stack ?? null }
});
}
};
declare function decodeTask(payload: unknown): unknown;
declare function runComputation(input: unknown): unknown;
Heap snapshot extraction from the worker’s own VM instance reveals hidden retention chains that a main-thread snapshot cannot see at all, and CPU flame charts for worker threads appear as separate tracks in the Performance panel — walked through in Profiling Worker CPU Usage with the Chrome Performance Tab.
Memory Profiling & Garbage Collection in Isolated Contexts
Isolated execution contexts maintain independent garbage collection roots. Structured clone operations trigger deep heap allocations during message serialization — the receiving realm allocates a complete second copy of the object graph, so a 20 MB payload briefly costs 40 MB across the two heaps. Circular references across boundaries cause silent retention spikes. Detached ArrayBuffer views frequently leak when transfer protocols mismatch, because the main thread keeps the now-useless view object alive while believing the memory was handed off.
Worker leaks fall into a small number of recognisable classes:
| Leak class | Typical shape | Signature in a snapshot diff |
|---|---|---|
| Module-scope cache | const cache = new Map() at the top of the worker module, never evicted |
One constructor whose retained size grows linearly with request count |
| Listener accumulation | addEventListener('message', …) registered per task instead of once |
Growing count of closure objects retaining their captured payload |
| Un-nulled transfer source | Main thread keeps the Uint8Array view after transferring its buffer |
Many zero-length typed arrays with a live retaining path |
| Zombie workers | Pool spawns but never reaps; each worker holds a full module scope | Flat heap in the page, rising total process memory |
| Promise chain retention | A pending promise per in-flight task that never settles after a crash | Growing count of resolve/reject closures with no matching task |
Identifying Memory Leaks in Workers establishes a repeatable protocol: capture baseline and post-workload heap snapshots, use the DevTools Comparison view to isolate growing constructor types, and apply WeakRef / FinalizationRegistry for cache eviction. Heap Snapshot Diffing for Worker Leaks goes further — walking through a step-by-step diff of two snapshots to pinpoint the exact retained constructor and its retaining path.
The discipline that makes diffing work is simple: always compare the same phase of the cycle. Snapshot at idle, run exactly N full workloads, return to idle, force a collection from the Memory panel, then snapshot again. Anything whose instance count does not return to its baseline is a candidate. Explicit release strategies use WeakRef and FinalizationRegistry for opportunistic cleanup, but treat them as observability tools rather than guarantees — the callbacks are not deterministic and may never run before the page unloads.
// worker-side (memory-tracker.ts)
export class WorkerHeapTracker {
private registry = new FinalizationRegistry((id: string) => {
// Fires opportunistically after collection — useful as a leak *signal*,
// never as a cleanup guarantee. Missing callbacks are the leak evidence.
console.log(`[Worker] GC reclaimed: ${id}`);
postMessage({ type: 'gc:reclaimed', id });
});
track(id: string, obj: object) {
this.registry.register(obj, id);
const heapMB = ((performance.memory?.usedJSHeapSize ?? 0) / 1024 / 1024).toFixed(1);
console.log(`[Worker] Tracking: ${id} | Heap: ${heapMB}MB`);
}
getSnapshot() {
return {
timestamp: performance.now(),
usedHeap: performance.memory?.usedJSHeapSize ?? 0,
totalHeap: performance.memory?.totalJSHeapSize ?? 0
};
}
}
Note: performance.memory is a Chromium-only, non-standard API. Use it as a rough guide in development; it is not available in Firefox or Safari. For a standardised, cross-realm figure, Chromium also exposes performance.measureUserAgentSpecificMemory(), which reports per-context byte estimates but requires cross-origin isolation and resolves only after the engine schedules a measurement — accurate, but far too slow for per-task sampling.
Serialization Overhead & Message Passing Optimization
Cross-thread communication latency scales with payload complexity, not just payload size. Structured cloning walks the object graph node by node, so 1 MB spread over 200,000 small objects costs far more than 1 MB in a single ArrayBuffer: expect roughly 3–8 ms per megabyte for object graphs versus well under a millisecond for a flat binary buffer of the same size. Transferable objects bypass copying entirely by moving ownership of the underlying memory, reducing the handoff to a pointer update. Batching strategies minimise event-loop dispatch frequency, which matters because each message also costs a task-queue round trip on both sides.
postMessage Bottleneck Analysis quantifies serialization latency under production loads and provides a step-by-step diagnostic workflow: recording a Performance trace, filtering for the clone work that precedes dispatch, and validating the optimization with round-trip performance.now() measurements. The companion deep-dive Measuring Structured Clone Cost with performance.now() provides a minimal reproducible benchmark for quantifying clone cost in isolation, and Message Passing Strategies covers the envelope designs that keep those payloads cheap in the first place.
Two failure modes belong to this section specifically, and they are easy to confuse:
DataCloneErroris thrown synchronously on the sending side when the payload contains something the structured clone algorithm refuses — a function, a DOM node, aProxy, a class instance with methods you expected to survive (only its own enumerable data properties survive; the prototype does not). The send never happens.messageerrorfires as an event on the receiving side when a message arrives but cannot be deserialized in that realm. It is a separate event frommessage, so a port with only anonmessagehandler drops these silently. Registeronmessageerroron everyWorkerandMessagePortyou own.
Zero-copy architectures use SharedArrayBuffer and Atomics for lock-free synchronization, as covered in SharedArrayBuffer & Atomics. Message routers must validate transferable ownership before dispatch: transferring the same ArrayBuffer twice throws, and reading a detached buffer yields a zero-length view rather than the data you expect.
// main-thread.ts (zero-copy-router.ts)
export class ZeroCopyMessageRouter {
// A pool of pre-allocated buffers avoids allocating (and later collecting)
// a megabyte per batch — allocation churn shows up as GC pauses on the
// main thread, which is exactly the jank workers were meant to remove.
private pool: ArrayBuffer[] = [];
private worker: Worker;
private batchSize = 4;
private queue: Uint8Array[] = [];
constructor(worker: Worker, initialPoolSize = 8, byteLength = 1024 * 1024) {
this.worker = worker;
for (let i = 0; i < initialPoolSize; i++) {
this.pool.push(new ArrayBuffer(byteLength));
}
}
enqueue(data: Uint8Array) {
this.queue.push(data);
if (this.queue.length >= this.batchSize) this.flush();
}
flush() {
if (this.queue.length === 0 || this.pool.length === 0) return;
const chunk = this.queue.splice(0, this.batchSize);
const transferables: ArrayBuffer[] = [];
const payload = chunk.map((data, i) => {
const buffer = this.pool.shift()!;
new Uint8Array(buffer).set(data);
transferables.push(buffer);
return { id: i, buffer };
});
// Second argument is the transfer list: ownership moves, nothing is copied.
// After this call every buffer in `transferables` is detached here.
this.worker.postMessage({ type: 'batch', data: payload }, transferables);
}
reclaim(buffer: ArrayBuffer) {
// The worker must post the buffer back for the pool to stay non-empty;
// a worker that forgets to return buffers starves this router silently.
this.pool.push(buffer);
if (this.queue.length > 0) this.flush();
}
}
Performance Envelope: Transfer Strategies and Profiling Budgets
The choice of data transfer mechanism is the single biggest lever on worker throughput. The table below uses typical figures from Chrome 124 on a mid-range desktop with a 1 MB payload.
| Mechanism | Transfer latency (1 MB) | CPU cost | Concurrency safety |
|---|---|---|---|
| Structured clone | 3–8 ms | High (recursive copy) | Implicit — deep copy |
Transferable ArrayBuffer |
< 0.1 ms | Negligible | Safe — single owner |
SharedArrayBuffer |
~0 ms | ~0 ms | Requires Atomics |
Latency alone does not decide the design. The second table is the one to argue from in review:
| Mechanism | Use when | Avoid when | Main cost you accept |
|---|---|---|---|
| Structured clone | Payloads under ~50 KB, or plain JSON-shaped data where simplicity wins | Payload exceeds 1 MB, or the same data is sent every frame | Copy time on the calling thread, doubled peak memory |
| Transferable objects | One-way handoff of binary data: image bitmaps, decoded audio, parsed columnar buffers | Both sides need the data afterwards | Source is detached — you must design the return path |
SharedArrayBuffer + Atomics |
Multiple readers, sub-millisecond coordination, sustained streaming | You cannot set COOP/COEP, or the access pattern is not naturally lock-free | Cross-origin isolation constraints and real concurrency bugs |
Concrete budgets to hold a design to, on a mid-range 2023 laptop:
- Dispatch overhead for a small message round trip: 0.1–0.3 ms. If your traces show more, you are measuring clone cost, not messaging.
- Frame budget: 16.7 ms at 60 Hz. Any main-thread block above ~8 ms in a frame that also renders is visible jank, which is why the clone cost of a 2 MB object graph is a bug rather than a tax.
- Worker startup: 1–3 ms plus script evaluation. Pre-warm one worker at idle if your first interaction is latency-sensitive.
- Break-even for transfer: above roughly 100 KB of binary data, transferring is measurably cheaper than cloning; below that the difference is inside the noise.
Transferring a 50 MB ArrayBuffer via the transfer list is sub-millisecond. Structured-cloning the same buffer copies ~50 MB and blocks the calling thread for 10–20 ms. For any payload above 1 MB, always prefer Transferable Objects & Zero-Copy semantics.
Security & Compatibility Constraints
Shared memory is gated behind cross-origin isolation, and the gate is silent: without the headers, SharedArrayBuffer is simply undefined rather than throwing something you can catch and diagnose. Feature-detect with crossOriginIsolated before building any code path on shared memory, and keep a postMessage fallback for documents that cannot be isolated — embedding third-party iframes or scripts without CORP headers will break isolation for the whole document.
SharedArrayBuffer requires Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp on the document. Without cross-origin isolation, SharedArrayBuffer is simply undefined — and window.crossOriginIsolated will be false. Test this before deploying any shared-memory pipeline.
undefined rather than an exception, the only reliable check is crossOriginIsolated at runtime — with a postMessage path ready behind it.Engine-specific behaviours worth knowing before you trust a measurement:
Error.stackacross the boundary. Chromium preserves it through structured clone; other engines do not guarantee it. Always flatten errors to plain objects before posting, as shown above and detailed in Fixing Uncaught Exceptions in Dedicated Workers.performance.memoryexists only in Chromium and reports quantised values; treat it as a trend line, never as an assertion in a test.navigator.hardwareConcurrencyis a hint. Safari clamps the reported value, and on mobile the “cores” are heterogeneous — four efficiency cores do not deliver four cores of throughput.- Module workers (
{ type: 'module' }) reached Safari in 15 and Firefox in 114. If you must support older engines, ship a classic-worker bundle built withimportScriptsas a fallback.
An out-of-memory kill terminates the worker without firing error on the Worker handle. From the main thread it is indistinguishable from a hung computation. A heartbeat every 1–2 s is the only portable way to tell the two apart — and the only way to trigger a restart before the user notices.
Browser compatibility
| Feature | Chrome | Firefox | Safari | Edge |
|---|---|---|---|---|
Worker + postMessage |
4+ | 3.5+ | 4+ | 12+ |
Transferable ArrayBuffer |
17+ | 18+ | 6+ | 12+ |
performance.mark() in worker |
43+ | 40+ | 11+ | 79+ |
Module workers (type: 'module') |
80+ | 114+ | 15+ | 80+ |
SharedArrayBuffer (with COOP/COEP) |
92+ | 79+ | 15.2+ | 92+ |
FinalizationRegistry |
84+ | 79+ | 14.1+ | 84+ |
crossOriginIsolated |
87+ | 72+ | 15.2+ | 87+ |
messageerror event |
60+ | 57+ | 12+ | 79+ |
Fault Tolerance & Production Resilience
Background tasks fail silently without explicit error boundaries. Unhandled promise rejections in workers do not automatically propagate to the main thread — you must register self.addEventListener('unhandledrejection', ...) in every worker. Worker respawn logic requires exponential backoff and circuit-breaker patterns, otherwise a worker that crashes on a poison payload will be recreated in a tight loop and take the tab’s memory with it. State reconciliation after failure prevents data corruption when a task is retried on a fresh worker.
Error Handling & Crash Recovery covers the complete lifecycle: worker factory patterns with explicit state machines (IDLE → RUNNING → RECOVERING → TERMINATED), heartbeat-based hang detection, automatic restart with state hydration, and sandboxed execution boundaries for untrusted payloads. For single-page apps that create and destroy workers on navigation, Handling Worker Termination Gracefully in SPAs covers the teardown half of the same contract.
For production systems where DevTools is unavailable, Production Error Telemetry describes how to serialize worker stack traces, ship them to Sentry or a custom endpoint, and structure error payloads so they survive the cross-thread boundary without losing context. Capturing Worker Stack Traces in Sentry covers source-map upload so those frames resolve to your original TypeScript rather than a minified chunk offset.
The circuit breaker below is deliberately conservative: three failures open the circuit, the worker is destroyed rather than reused, and the reopen delay grows exponentially so a systemic outage does not become a spawn storm.
// main-thread.ts (circuit-breaker.ts)
export class WorkerCircuitBreaker {
private worker: Worker | null = null;
private failureCount = 0;
private readonly maxFailures = 3;
private readonly backoffMs = 1000;
private state: 'CLOSED' | 'OPEN' | 'HALF_OPEN' = 'CLOSED';
constructor(private readonly scriptURL: string) {}
async execute<T>(task: unknown): Promise<T> {
if (this.state === 'OPEN') throw new Error('Circuit breaker open. Retry later.');
if (!this.worker) {
this.worker = new Worker(this.scriptURL, { type: 'module' });
}
return new Promise<T>((resolve, reject) => {
// The timeout is the only detector for an OOM kill or an infinite loop,
// because neither of those dispatches an error event.
const timeout = setTimeout(() => {
this.onFailure();
reject(new Error('Worker timeout'));
}, 5000);
this.worker!.onmessage = (e) => {
clearTimeout(timeout);
this.onSuccess();
resolve(e.data.result as T);
};
this.worker!.onerror = (err) => {
clearTimeout(timeout);
this.onFailure();
reject(err);
};
this.worker!.postMessage(task);
});
}
private onSuccess() {
this.failureCount = 0;
if (this.state === 'HALF_OPEN') this.state = 'CLOSED';
}
private onFailure() {
this.failureCount++;
// Destroy rather than reuse: a worker that timed out may still be running
// the previous task and would corrupt the next result.
this.worker?.terminate();
this.worker = null;
if (this.failureCount >= this.maxFailures) {
this.state = 'OPEN';
setTimeout(() => (this.state = 'HALF_OPEN'), this.backoffMs * Math.pow(2, this.failureCount));
}
}
destroy() {
this.worker?.terminate();
this.worker = null;
}
}
Advanced Patterns: Pool Sizing, Backpressure, and Drain Protocols
Three extensions separate a demo worker pool from one that survives a production traffic spike. Each is a direct response to a failure this reference has already named.
Pool sizing under measurement
Dynamic thread allocation must respect navigator.hardwareConcurrency limits. Over-provisioning triggers OS-level scheduling overhead and browser throttling without adding throughput: past the physical core count, workers compete for the same execution units and each task’s wall-clock latency rises while total throughput stays flat. Start at Math.min(navigator.hardwareConcurrency, 8), then measure — plot total tasks per second against pool size for your real payloads and pick the knee of the curve, not the maximum. Remember that the main thread also needs a core to render on; a pool sized to every reported core will make the UI compete with your own work.
Credit-based backpressure
An unbounded queue is a memory leak with extra steps. If producers outpace the pool — a websocket firing 200 messages a second into a pipeline that clears 60 — the queue absorbs the difference until the tab dies. Credit-based flow control inverts the relationship: the consumer grants the producer a fixed number of outstanding tasks, and the producer must wait for a credit before sending more. The queue depth becomes a constant you chose rather than a variable the network chose.
Drain before terminate
terminate() discards everything in flight. A drain protocol makes shutdown observable: stop accepting new work, wait for outstanding tasks to settle (or reject them explicitly after a deadline), flush the measure buffer and any pending telemetry, and only then terminate. This is what turns “the last batch sometimes disappears on navigation” into a bug you never file.
// main-thread.ts (backpressure-dispatcher.ts)
type Settle<T> = { resolve: (v: T) => void; reject: (e: Error) => void };
export class BackpressuredDispatcher<T = unknown> {
private inFlight = new Map<string, Settle<T>>();
private waiters: Array<() => void> = [];
private draining = false;
/**
* `credits` is the maximum number of unacknowledged tasks. Choose it from a
* measured latency budget: credits ≈ target_latency_ms / mean_task_ms.
*/
constructor(
private readonly worker: Worker,
private readonly credits: number = 4
) {
this.worker.onmessage = (e: MessageEvent<{ id: string; result?: T; error?: { message: string } }>) => {
const settle = this.inFlight.get(e.data.id);
if (!settle) return; // late reply for a task we already gave up on
this.inFlight.delete(e.data.id);
if (e.data.error) settle.reject(new Error(e.data.error.message));
else settle.resolve(e.data.result as T);
this.releaseCredit();
};
// Deserialization failures arrive here, NOT on onmessage — without this
// handler a malformed reply looks exactly like a hung task.
this.worker.onmessageerror = () => this.releaseCredit();
}
/** Resolves only once a credit is free, so callers are throttled at the source. */
private acquireCredit(): Promise<void> {
if (this.inFlight.size < this.credits) return Promise.resolve();
return new Promise<void>((resolve) => this.waiters.push(resolve));
}
private releaseCredit() {
const next = this.waiters.shift();
if (next) next();
}
async submit(payload: unknown, transfer: Transferable[] = []): Promise<T> {
if (this.draining) throw new Error('Dispatcher is draining; no new work accepted');
await this.acquireCredit(); // <-- the backpressure point
const id = crypto.randomUUID();
return new Promise<T>((resolve, reject) => {
this.inFlight.set(id, { resolve, reject });
this.worker.postMessage({ id, payload }, transfer);
});
}
/**
* Stops accepting work, waits up to `deadlineMs` for in-flight tasks to
* settle, then rejects the stragglers and terminates. Call this on route
* change or `pagehide` so the final batch is never silently dropped.
*/
async drain(deadlineMs = 3000): Promise<void> {
this.draining = true;
const started = performance.now();
while (this.inFlight.size > 0 && performance.now() - started < deadlineMs) {
await new Promise((r) => setTimeout(r, 50));
}
for (const [, settle] of this.inFlight) {
settle.reject(new Error('Dispatcher drained before task completed'));
}
this.inFlight.clear();
this.worker.terminate();
}
}
Read the submit method as the whole pattern in miniature: the await this.acquireCredit() line is the only thing standing between a burst of producer events and an unbounded queue, and it costs one promise per task. Pair it with the correlation ids from the pool above and every task in the system has a bounded queue position, a measurable wait, and a guaranteed terminal state.
Background Tabs, Throttling, and Scheduler Interference
Measurements taken in a focused tab do not describe what happens when the user switches away. Browsers aggressively throttle background pages: timers are clamped (typically to once per minute after a few minutes in the background in Chromium), rendering stops, and CPU allocation for the page’s threads is reduced. Workers are not frozen the way timers are, but their scheduling is not guaranteed either, and behaviour differs by vendor, version, and power state.
The practical consequences for a worker pipeline:
- Do not drive timing from
setIntervalon the main thread. A polling loop that ticks every 100 ms in the foreground may tick once a minute in the background, making a healthy worker look hung. - Detect throttling rather than assuming it. Compare expected against observed heartbeat intervals; when the gap widens, back off polling and buffer instead of firing retries.
- Handle
pagehideandvisibilitychange. Drain in-flight work and flush telemetry withnavigator.sendBeaconorfetch(..., { keepalive: true })— a normalfetchissued during unload is frequently cancelled. - Expect resumed tabs to burst. After a long background period, everything queued fires at once. Bounded queues and credits are what stop that burst from becoming an out-of-memory kill.
Production Performance Checklist
- Cap concurrent worker instantiation to
Math.min(navigator.hardwareConcurrency, 8), then tune from measured throughput rather than from the core count. - Prefer
Transferableobjects over structured cloning for payloads exceeding 1 MB; the break-even for binary data sits near 100 KB. - Give every worker and every task a correlation id so queue wait, transfer time, and compute time can be separated in a trace.
- Implement idle detection to trigger graceful
worker.terminate()calls, and always drain before terminating. - Bound every queue — credits, a max depth, or a rejection policy. An unbounded queue is a memory leak.
- Register
unhandledrejectionandonmessageerrorlisteners in every worker and on every port, so async failures and deserialization failures are both visible. - Add a 1–2 s heartbeat: it is the only portable detector for an out-of-memory kill, which fires no event.
- Avoid synchronous
XMLHttpRequestin workers — it blocks the worker’s event loop and provides no benefit overfetch. - Quantify serialization overhead for your real payloads: structured clone ~3–8 ms/MB for object graphs, transferables ~0.05 ms,
SharedArrayBuffer~0 ms. - Diff heap snapshots at the same phase of the workload cycle in long-running sessions to catch retention chains early.
- Verify
crossOriginIsolatedat runtime before any shared-memory path, and keep apostMessagefallback. - In production, route serialized error payloads to a telemetry endpoint rather than relying on
console.error, and upload worker source maps with every release.
Newer Ground in This Section
Testing Workers in CI closes the loop between the debugging techniques above and keeping a worker correct over time. It splits the problem into four layers — the algorithm, the protocol, the boundary and the integration — and puts each in the environment that can actually test it: hundreds of fast tests with no thread at all, a small deliberate set against a real Worker in a browser, and a handful of end-to-end runs against the production build.
The layer that matters most in practice is the one that removes timing assumptions. A test that waits a fixed interval for a worker to reply passes on a laptop and fails on a two-core runner; a test that awaits a correlated reply with a generous failure timeout is stable everywhere. The same page covers the CI-specific failures that make a green local run meaningless — core counts that change pool behaviour, origins that reject a worker URL, and the isolation headers a shared-memory test needs from the test server itself.