Error Handling & Crash Recovery
A Web Worker that throws does not take your page down — it takes itself down, quietly, and leaves the main thread holding a message queue that will never drain. This guide builds the supervisor layer that closes that gap: explicit lifecycle states, interception of every failure channel, backoff restarts, and deterministic state rehydration. It is one of the core disciplines in Debugging, Profiling & Production Optimization, and it shares its instrumentation with the profiling work described there — the same performance.mark() calls that measure throughput also measure how long a recovery takes.
Everything below assumes you want failures to be observable as well as survivable. Once the recovery machinery is in place, Production Error Telemetry covers shipping the serialized error payloads to Sentry or a custom endpoint so crashes surface in a dashboard before a user reports them.
When a worker dies, the main thread often does not notice
The concrete symptom looks like this. A dashboard offloads a 40 MB GeoJSON simplification to a worker. On a subset of inputs, a polygon with a self-intersecting ring makes the geometry library throw. The worker’s event loop unwinds, the thread stops, and on the main thread the spinner keeps spinning — forever. No console error surfaces in production because the app registered worker.onmessage but never worker.onerror. Users report “the map never loads” and the error dashboard shows nothing at all.
There are four distinct ways a worker stops doing useful work, and they surface through four different channels:
| Failure | What fires | What the main thread sees by default |
|---|---|---|
| Synchronous throw in worker code | ErrorEvent on the Worker object |
Nothing, unless worker.onerror is bound |
| Unhandled promise rejection inside the worker | unhandledrejection on self inside the worker only |
Nothing at all |
| Non-cloneable message in either direction | messageerror on the receiving port |
Nothing, unless onmessageerror is bound |
| Infinite loop, deadlock, or OOM kill | Nothing — the thread is alive or gone silently | Nothing; the queue simply stops draining |
The last row is the dangerous one. An out-of-memory kill by the browser fires no event in any engine, and a while (true) in a worker is indistinguishable from a very slow task. Only an active liveness probe can tell them apart, which is why a heartbeat is not optional in a production supervisor.
onerror the failure is dispatched into the void and the queue stops draining; with a supervisor the crash is classified, backed off, respawned and rehydrated inside about 280 ms.Prerequisites
Before implementing any of the steps below, confirm these are true of your setup:
- Workers are constructed as module workers —
new Worker(url, { type: 'module' })— soimportworks inside the worker and each script has its own strict-mode scope. If you are still shipping classic workers built from blob URLs, read Inline Workers vs Dedicated Workers first, because blob-backed workers have no stable filename for stack frames. - Every worker entry module registers its
errorandunhandledrejectionlisteners before any top-levelawait. A listener installed after an await is not installed during the module’s own evaluation, which is exactly when startup errors happen. - Source maps are emitted for each worker chunk. Without them a production stack trace is
worker.min.js:1:48213and the recovery logs are unusable — see Chrome DevTools Worker Debugging for the source-map and thread-attachment setup. - The main thread owns a single supervisor object per worker, not scattered
onerrorclosures. Duplicate recovery paths are the most common bug in this area:onerrorand a heartbeat timeout both fire, and you end up with two live replacement workers competing for the same job queue. - If any part of the pipeline uses
SharedArrayBuffer, the document is already cross-origin isolated (Cross-Origin-Opener-Policy: same-originplusCross-Origin-Embedder-Policy: require-corp) andself.crossOriginIsolatedevaluates totrue.
Building the recovery pipeline
The seven steps below compose into one supervisor class. Each step is independently useful, but the order matters: the state machine must exist before the error routing, because routing decisions are state transitions.
1. Model the lifecycle as an explicit state machine
Recovery bugs are almost always concurrency bugs: two signals arrive for the same failure and both start a restart. A guarded state machine makes the second signal a no-op by construction.
// main-thread/supervisor-state.ts
export type SupervisorState =
| 'idle' // constructed, no worker spawned yet
| 'starting' // worker created, waiting for the READY handshake
| 'running' // handshake complete, accepting jobs
| 'recovering' // failure observed, backoff timer pending
| 'terminated'; // permanently dead: retries exhausted or explicit stop
const ALLOWED: Record<SupervisorState, readonly SupervisorState[]> = {
idle: ['starting', 'terminated'],
starting: ['running', 'recovering', 'terminated'],
running: ['recovering', 'terminated'],
recovering: ['starting', 'terminated'],
terminated: []
};
export class LifecycleGuard {
private state: SupervisorState = 'idle';
/** Transitions rejected because the state already moved on. */
public droppedTransitions = 0;
get current(): SupervisorState {
return this.state;
}
/** Returns true only if the transition was legal and applied. */
transition(next: SupervisorState): boolean {
if (!ALLOWED[this.state].includes(next)) {
// A second failure signal for an already-handled crash lands here.
this.droppedTransitions++;
return false;
}
this.state = next;
return true;
}
}
Throwing on an illegal transition surfaces logic bugs immediately in development, but in production it converts a handled crash into an unhandled one on the main thread. Returning false and counting droppedTransitions keeps the page alive and still gives you a metric to alarm on — a supervisor that drops hundreds of transitions per session is telling you two failure detectors are racing.
2. Trap every failure channel inside the worker
The worker is the only place that can see its own rejected promises. Put the trap in a dedicated bootstrap module and make it the first import of every worker entry point, so it is evaluated before any application code can throw. The exact event mapping — including which properties are populated for a compile error versus a runtime throw — is covered in Fixing Uncaught Exceptions in Dedicated Workers.
// worker/bootstrap.js — must be the first import in every worker entry module
export function serializeError(value) {
if (value instanceof Error) {
return {
name: value.name,
message: value.message,
// Keep the first frames only: full stacks from deep library recursion
// can exceed 100 KB and dominate the postMessage payload.
stack: (value.stack ?? '').split('\n').slice(0, 12).join('\n'),
code: value.code, // DOMException / Node-style codes
cause: value.cause ? String(value.cause) : undefined
};
}
// Rejections carry arbitrary values: strings, numbers, plain objects.
return { name: 'NonError', message: String(value), stack: null };
}
function report(kind, value, extra = {}) {
self.postMessage({
channel: '__worker_error__',
kind, // 'throw' | 'rejection'
error: serializeError(value),
at: performance.now(),
...extra
});
}
self.addEventListener('error', (event) => {
// event.error is null for cross-origin script load failures.
report('throw', event.error ?? event.message, {
filename: event.filename,
lineno: event.lineno,
colno: event.colno
});
});
self.addEventListener('unhandledrejection', (event) => {
// Suppress the default "unhandled rejection" abort so the thread stays
// alive long enough to flush this message and any pending checkpoint.
event.preventDefault();
report('rejection', event.reason);
});
self.addEventListener('messageerror', (event) => {
report('throw', new TypeError('inbound message failed to deserialize'), {
origin: event.origin
});
});
Calling event.preventDefault() on unhandledrejection stops the engine from tearing the worker down, which buys you a clean checkpoint flush. The cost is that a worker whose internal state is now corrupt keeps accepting jobs. Treat suppression as a chance to report and then voluntarily stop: post the error, post the final checkpoint, then call self.close() and let the supervisor spawn a clean replacement.
3. Route and classify errors on the main thread
Three different listeners on the Worker object see three different classes of failure, and they demand different responses. A load failure will fail identically on retry; a runtime fault probably will not.
// main-thread/classify.ts
export type FaultClass =
| 'load-failure' // script fetch, CORS, or module resolution failed
| 'runtime-fault' // application code threw or rejected
| 'message-fault'; // structured clone failed at the boundary
export interface Fault {
class: FaultClass;
message: string;
stack: string | null;
filename?: string;
lineno?: number;
retryable: boolean;
}
export function classifyErrorEvent(event: ErrorEvent): Fault {
// A load failure surfaces with an empty message and no source location,
// because the worker global never came into existence to attribute it to.
const isLoadFailure =
(!event.message || event.message === 'Failed to load') && !event.lineno;
return {
class: isLoadFailure ? 'load-failure' : 'runtime-fault',
message: event.message || 'worker script failed to load',
stack: event.error?.stack ?? null,
filename: event.filename,
lineno: event.lineno,
// Respawning a worker whose URL 404s just burns the retry budget.
retryable: !isLoadFailure
};
}
export function bindFaultChannels(
worker: Worker,
onFault: (fault: Fault) => void
): void {
worker.onerror = (event: ErrorEvent) => {
// Suppress the browser's default console print in production builds only;
// in development the native message carries a clickable source location.
if (import.meta.env?.PROD) event.preventDefault();
onFault(classifyErrorEvent(event));
};
worker.onmessageerror = (event: MessageEvent) => {
onFault({
class: 'message-fault',
message: 'outbound message could not be deserialized by the worker',
stack: null,
retryable: false // the payload is the problem, not the worker
});
};
}
event.preventDefault() on an ErrorEvent suppresses Chrome's console print, which is what you want in production where your own telemetry owns the record. In development it hides the one artefact that has a working source link, so gate it behind the build flag rather than shipping it unconditionally.
onmessageerror is the handler everyone forgets
onmessageerror fires when the receiving side cannot deserialize an incoming message — a value the structured clone algorithm rejects, or a transferred buffer that was already detached. Without this handler the message vanishes and the sender waits on a reply that will never arrive. Bind it every time you bind onerror. The rules for which values survive the boundary are set out in the structured clone algorithm walkthrough.
4. Detect silent hangs with a heartbeat
Nothing in the platform tells you a worker’s event loop is wedged. A PING/PONG probe with a deadline is the only portable liveness signal, and it costs almost nothing: a two-property message clones in well under 0.05 ms.
// main-thread/heartbeat.ts
interface HeartbeatOptions {
intervalMs?: number; // how often to probe
timeoutMs?: number; // how long a single PONG may take
missesAllowed?: number;
}
export class Heartbeat {
private timer: ReturnType<typeof setInterval> | null = null;
private misses = 0;
private awaitingSince: number | null = null;
constructor(
private worker: Worker,
private onDead: (missedMs: number) => void,
private opts: HeartbeatOptions = {}
) {}
start(): void {
const { intervalMs = 2000, timeoutMs = 1500, missesAllowed = 2 } = this.opts;
this.timer = setInterval(() => {
if (this.awaitingSince !== null) {
const waited = performance.now() - this.awaitingSince;
if (waited > timeoutMs && ++this.misses >= missesAllowed) {
this.stop();
this.onDead(waited);
return;
}
}
this.awaitingSince = performance.now();
this.worker.postMessage({ channel: '__heartbeat__', type: 'PING' });
}, intervalMs);
}
/** Call from the supervisor's message handler when a PONG arrives. */
acknowledge(): void {
this.awaitingSince = null;
this.misses = 0;
}
stop(): void {
if (this.timer !== null) clearInterval(this.timer);
this.timer = null;
}
}
The worker side is three lines, but it has to run ahead of the task handler so a queued long task cannot delay the reply past the point of usefulness:
// worker/heartbeat.js
self.addEventListener('message', (event) => {
if (event.data?.channel === '__heartbeat__') {
self.postMessage({ channel: '__heartbeat__', type: 'PONG' });
}
});
A worker running one long synchronous task will miss its PONG deadline even though it is working perfectly — the reply is queued behind the task on the same event loop. Either chunk long tasks so control returns to the loop every 16–50 ms, or have the task itself emit progress messages that the supervisor accepts as liveness. Firing terminate() at a worker that was 90% through a 30-second job is a self-inflicted outage.
5. Restart with exponential backoff and jitter
A failure that is caused by external conditions — a 503 from an API the worker calls, memory pressure from another tab — will recur if you retry instantly. Exponential backoff spreads retries out; jitter stops a page that lost eight pool workers at once from respawning them in a synchronised thundering herd.
// main-thread/backoff.ts
export interface BackoffPolicy {
baseMs: number;
capMs: number;
maxAttempts: number;
/** Healthy uptime after which the attempt counter resets. */
stabilityWindowMs: number;
}
export const DEFAULT_POLICY: BackoffPolicy = {
baseMs: 250,
capMs: 10_000,
maxAttempts: 5,
stabilityWindowMs: 30_000
};
/** Full jitter: uniform in [0, min(cap, base * 2^attempt)). */
export function backoffDelay(attempt: number, policy = DEFAULT_POLICY): number {
const ceiling = Math.min(policy.capMs, policy.baseMs * 2 ** attempt);
return Math.random() * ceiling;
}
export class RetryBudget {
private attempts = 0;
private lastHealthyAt = 0;
/** Returns the delay to wait, or null when the budget is exhausted. */
nextDelay(policy = DEFAULT_POLICY): number | null {
const uptime = performance.now() - this.lastHealthyAt;
if (this.lastHealthyAt > 0 && uptime > policy.stabilityWindowMs) {
this.attempts = 0; // the worker earned a clean slate
}
if (this.attempts >= policy.maxAttempts) return null;
return backoffDelay(this.attempts++, policy);
}
markHealthy(): void {
this.lastHealthyAt = performance.now();
}
}
With the default policy the worst-case ceilings are 250 ms, 500 ms, 1 s, 2 s and 4 s, and full jitter halves those on average — so five attempts complete in roughly 4 seconds of expected wall-clock delay before the circuit opens. That is short enough to be invisible during a transient fault and long enough that a deterministic crash cannot spin the CPU.
A per-worker budget lets one poisoned input kill one thread while the rest of the pool keeps serving. A pool-wide budget notices the systemic case — every worker dying on the same corrupt shared asset — and opens the circuit once instead of five times. Production pools usually want both: a small per-worker budget plus a pool-level breaker. Sizing and replacement policy interact directly with Worker Pool Management, where the queue must be re-balanced onto surviving threads while a replacement warms up.
6. Rehydrate the replacement worker from a checkpoint
A fresh worker starts with an empty heap. If the crashed worker held twenty minutes of accumulated aggregation state, the restart is only useful if that state comes back. Keep the authoritative copy on the main thread as a small ring buffer of checkpoints, and make hydration part of the startup handshake.
// main-thread/hydration.ts
export interface Checkpoint<T> {
seq: number;
at: number;
state: T;
}
export class CheckpointRing<T> {
private slots: Array<Checkpoint<T>> = [];
constructor(private capacity = 3) {}
push(state: T, seq: number): void {
this.slots.push({ seq, at: performance.now(), state });
if (this.slots.length > this.capacity) this.slots.shift();
}
/** Newest first, so a rejected checkpoint can fall back to an older one. */
candidates(): Array<Checkpoint<T>> {
return [...this.slots].reverse();
}
}
export function spawnHydrated<T>(
url: URL,
checkpoint: Checkpoint<T> | undefined,
timeoutMs = 5000
): Promise<Worker> {
const worker = new Worker(url, { type: 'module' });
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
worker.terminate();
reject(new Error(`hydration timed out after ${timeoutMs}ms`));
}, timeoutMs);
worker.addEventListener('message', function onReady(event: MessageEvent) {
if (event.data?.type !== 'HYDRATION_ACK') return;
worker.removeEventListener('message', onReady);
clearTimeout(timer);
// The worker validated the checkpoint and is ready for jobs.
event.data.accepted ? resolve(worker) : reject(new Error('checkpoint rejected'));
});
worker.addEventListener('error', (event) => {
clearTimeout(timer);
worker.terminate();
reject(new Error(event.message || 'replacement worker failed to load'));
}, { once: true });
worker.postMessage({
type: 'HYDRATE',
// structuredClone here decouples the worker's copy from the ring buffer,
// so later mutations on the main thread cannot corrupt a stored slot.
payload: checkpoint ? structuredClone(checkpoint) : null
});
});
}
The worker validates before it accepts. A checkpoint written moments before a crash may be exactly what caused it, which is why candidates() returns the ring newest-first:
// worker/hydrate.js
let seq = 0;
let state = createEmptyState();
self.addEventListener('message', (event) => {
if (event.data?.type !== 'HYDRATE') return;
const checkpoint = event.data.payload;
try {
if (checkpoint) {
assertStateShape(checkpoint.state); // throws on a corrupt or partial snapshot
state = checkpoint.state;
seq = checkpoint.seq;
}
self.postMessage({ type: 'HYDRATION_ACK', accepted: true, seq });
} catch (err) {
// Refusing is better than resuming from a poisoned snapshot: the
// supervisor will offer the previous checkpoint, then a cold start.
self.postMessage({ type: 'HYDRATION_ACK', accepted: false, seq: -1 });
}
});
Checkpointing every result gives near-zero data loss and a permanent clone tax on the hot path. Checkpointing on a 500 ms timer bounds the tax to two clones per second while risking up to 500 ms of recomputation after a crash. Delta snapshots — sending only changed keys — keep both numbers small, at the cost of a reducer on the main thread that can replay deltas onto a base state. Watch the heap while you tune this: retained checkpoints are a classic worker leak, and Identifying Memory Leaks in Workers shows how to confirm the ring buffer is actually releasing old slots.
7. Quarantine untrusted or unbounded work
If the worker executes plugin code, user-authored expressions, or any payload shaped by a remote system, an execution boundary keeps a bad input from becoming a crash loop. Validate first, cap the runtime, and route violations to a quarantine channel that the supervisor treats as a job failure rather than a worker failure.
// worker/boundary.js
const MAX_EXECUTION_MS = 8000;
function validate(data) {
if (!data || typeof data !== 'object') throw new TypeError('payload must be an object');
if (typeof data.type !== 'string') throw new TypeError('payload.type must be a string');
if (data.rows != null && !ArrayBuffer.isView(data.rows)) {
throw new TypeError('payload.rows must be a typed array view');
}
return data;
}
self.addEventListener('message', (event) => {
if (event.data?.channel) return; // control channels handled elsewhere
const controller = new AbortController();
const deadline = setTimeout(() => controller.abort(), MAX_EXECUTION_MS);
let payload;
try {
payload = validate(event.data);
} catch (validationError) {
clearTimeout(deadline);
self.postMessage({ type: 'QUARANTINE', reason: validationError.message, jobId: event.data?.jobId });
return; // the worker survives; only this job is rejected
}
executeTask(payload, controller.signal)
.then((result) => self.postMessage({ type: 'RESULT', jobId: payload.jobId, result }))
.catch((err) => {
const reason = err.name === 'AbortError' ? 'EXECUTION_TIMEOUT' : err.message;
self.postMessage({ type: 'QUARANTINE', reason, jobId: payload.jobId });
})
.finally(() => clearTimeout(deadline));
});
controller.abort() only stops work that actually checks signal.aborted or passes the signal to an abortable API such as fetch. A tight synchronous loop in untrusted code ignores it completely, and the only remaining lever is worker.terminate() from the main thread — which is why the heartbeat in step 4 is the real backstop for hostile or buggy payloads. Treat the timeout as a courteous first attempt and forced termination as the guarantee.
Checkpoint transport: clone, transfer, or share
Recovery makes state cross the thread boundary far more often than a naive design does, so the transport choice stops being an optimisation detail and starts driving your recovery latency. Three mechanisms are available, and for checkpointing they behave very differently.
| Mechanism | Cost profile | Ownership after send | Fit for checkpoints |
|---|---|---|---|
Structured clone (postMessage(obj)) |
O(n) copy; deep object graphs are markedly slower per byte than flat typed arrays | Both sides keep an independent copy | Default choice. The copy is the point — the main thread’s snapshot must survive the worker’s death |
Transfer (postMessage(buf, [buf])) |
O(1) pointer handoff, independent of size | Sender’s buffer is detached and unusable | Good for the job payload, wrong for a checkpoint: a transferred snapshot dies with the worker |
SharedArrayBuffer + Atomics |
No copy at all; both threads read the same memory | Shared; requires explicit synchronisation | Only for large numeric state where the copy dominates, and only when the page is cross-origin isolated |
The default is structured clone, and for the usual reason people forget: a checkpoint’s value comes from being redundant. Transfer a snapshot into the worker and you have moved the only copy into the thread most likely to die. See Transferable Objects & Zero-Copy for the ownership semantics in detail, and note that a detached buffer read is one of the exact conditions that produces a messageerror rather than a throw.
If the state is a large numeric buffer — a simulation grid, an audio ring, a tile cache — sharing it changes the recovery story entirely: the memory outlives the worker, so a replacement can attach to the existing SharedArrayBuffer and continue without any hydration payload at all. That requires the document to be cross-origin isolated with Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp, and every cross-origin subresource to carry CORP or CORS headers. Guard the code path with self.crossOriginIsolated and keep a clone-based fallback, because a third-party embed without CORP headers will silently un-isolate the page. SharedArrayBuffer & Atomics covers the coordination primitives, and Debugging SharedArrayBuffer Cross-Origin Errors covers what to do when isolation breaks in production.
There is one shared-memory failure mode with no equivalent in the clone world: a worker that crashes while holding a lock. A mutex implemented with Atomics.wait has no owner-death detection, so the next thread waits forever. Either store an owner id and a heartbeat sequence alongside the lock word so a supervisor can break a stale lock, or keep mutations single-writer so a crash can never strand one.
Verification and measurement
Crash recovery is code that only runs when something has already gone wrong, which means it is the code most likely to be broken in production and never noticed. Test it by causing failures on purpose.
// test/chaos.ts — deterministic fault injection for the supervisor
type FaultKind = 'throw' | 'reject' | 'hang' | 'bad-message' | 'oom';
export function injectFault(worker: Worker, kind: FaultKind): void {
worker.postMessage({ channel: '__chaos__', kind });
}
// worker/chaos.js (excluded from production builds by a bundler define)
self.addEventListener('message', (event) => {
if (event.data?.channel !== '__chaos__') return;
switch (event.data.kind) {
case 'throw': throw new Error('chaos: synchronous throw');
case 'reject': Promise.reject(new Error('chaos: unhandled rejection')); break;
case 'hang': { const until = Date.now() + 60_000;
while (Date.now() < until) { /* wedge the loop */ } }
case 'bad-message': self.postMessage({ fn: () => {} }); break; // not cloneable
case 'oom': { const hog = []; for (;;) hog.push(new Float64Array(1e6)); }
}
});
Assert on four properties for each injected fault: the supervisor reached recovering, exactly one replacement worker was created (droppedTransitions proves the duplicate signal was absorbed), every job that was in flight either completed or was re-queued, and the retry counter reset after the stability window.
Then measure the recovery itself. Mark both ends of the sequence on the main thread — the worker that crashed cannot report its own funeral:
// main-thread/measure.ts
performance.mark('recovery:start'); // in the fault handler
// … backoff delay, spawn, hydration handshake …
performance.mark('recovery:ready'); // on HYDRATION_ACK
const m = performance.measure('recovery', 'recovery:start', 'recovery:ready');
console.info(`mean time to recovery: ${m.duration.toFixed(1)} ms`);
performance.clearMarks('recovery:start');
performance.clearMarks('recovery:ready');
performance.clearMeasures('recovery');
Two numbers are worth tracking as production metrics. Mean time to recovery is dominated by the backoff delay and the module-worker startup cost — expect a few hundred milliseconds for a cold module worker plus whatever hydration costs at your state size. Recovery amplification is the ratio of jobs re-executed to jobs lost; a ratio far above 1 means your checkpoint interval is too coarse and you are recomputing work you had already finished.
User-visible measurement matters too. Record a Performance trace across an injected crash and check the main thread for long tasks during the recovery — a hydration structuredClone of a multi-megabyte state runs synchronously on the main thread and shows up as jank exactly when the user is already waiting. The technique for separating clone cost from compute cost is in postMessage Bottleneck Analysis, and the thread-attachment workflow for stepping through the recovery path lives in Chrome DevTools Worker Debugging.
Failure modes and their signatures
Most production incidents in this area come from a handful of recurring mistakes. The signature column is what you will actually observe first.
| Signature | Cause | Fix |
|---|---|---|
| Spinner never resolves, console clean | worker.onerror never bound |
Bind all three channels in one place (step 3) |
| Two replacement workers after one crash | onerror and heartbeat timeout both triggered a restart |
Guard restarts behind the state machine (step 1) |
| Restart loop pinning a CPU core | Deterministic crash retried without a budget | Cap attempts and open a circuit breaker (step 5) |
| Whole pool respawns in the same millisecond | Exponential backoff without jitter | Multiply the delay by a random factor (step 5) |
Errors reported with null stack |
Error sent through structured clone in Firefox |
Serialize to a plain object in the worker (step 2) |
| Message silently disappears | Non-cloneable value or detached buffer; onmessageerror unbound |
Bind onmessageerror; validate the payload before sending |
| Replacement worker crashes identically on start | Rehydrated from the corrupt checkpoint that caused the crash | Validate on hydrate, fall back to the previous slot, then cold-start |
| Heap grows with each restart | Old worker’s listeners, timers, or checkpoint slots retained | Clear the heartbeat interval and drop references in terminate() |
| Crash goes unreported in production | Errors logged to the console only | Forward serialized errors to telemetry |
| Recovery works locally, never in production | Minified stacks and no source maps, so nobody trusts the alerts | Upload worker source maps per release |
Two of these deserve a note. Retained listeners are the leak that hurts most in single-page apps, because the supervisor usually outlives many workers: clearInterval on the heartbeat, removeEventListener on anything bound to the dead worker, and null the reference — the teardown discipline for route changes and unmounts is covered in Handling Worker Termination Gracefully in SPAs. And the serialization shape you choose in step 2 should match what your backend expects; Structured Error Serialization Across Threads has the canonical payload including DOMException and error subclasses.
Browser compatibility
| Feature | Chrome | Firefox | Safari | Edge |
|---|---|---|---|---|
worker.onerror |
4+ | 3.5+ | 4+ | 12+ |
worker.onmessageerror |
60+ | 57+ | 12+ | 18+ |
unhandledrejection in worker |
66+ | 69+ | 11+ | 79+ |
AbortController in worker |
66+ | 57+ | 11.1+ | 16+ |
structuredClone() |
98+ | 94+ | 15.4+ | 98+ |
Error survives structured clone |
98+ | 93+ (no stack) |
15.4+ | 98+ |
Module workers ({ type: 'module' }) |
80+ | 114+ | 15+ | 80+ |
performance.mark() in worker |
43+ | 40+ | 11+ | 79+ |
crossOriginIsolated / SharedArrayBuffer |
92+ | 79+ | 15.2+ | 92+ |
The binding constraint for most teams is module workers in Firefox 114+. If you support older Firefox, either ship a classic-worker build alongside the module build or bundle the worker into a single classic script — and remember that a classic worker built from a blob URL produces stack frames with a blob: filename, which most error backends cannot map to a source file. Everything else in this guide degrades gracefully: pre-serializing errors makes the Error-clone row irrelevant, and the heartbeat uses nothing newer than postMessage.
The recovery lifecycle at a glance
All seven steps collapse into a single set of legal transitions. A crash or a missed heartbeat moves the supervisor into recovery, where the backoff timer decides whether a replacement is spawned or the circuit opens for good.
onerror and a heartbeat timeout for the same crash — becomes a counted no-op instead.With the supervisor in place, the remaining work is observability: route the serialized payloads from step 2 into Production Error Telemetry so every recovery is recorded, and keep watching the heap across restarts using the techniques in Identifying Memory Leaks in Workers.
Going Further
One channel is missing from most workers entirely. The error event covers synchronous throws; every asynchronous failure — a rejected fetch, a failed instantiation, an await nobody caught — arrives through unhandledrejection instead, and a worker that listens only for the first reports nothing at all. Handling unhandledrejection Inside Workers wires both, normalises reasons that are not Error objects, and separates real failures from the phantom rejections that train teams to ignore the channel.