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.

Silent worker death versus a supervised recovery Two timelines spanning 500 milliseconds. On the unsupervised track the worker runs, throws at 120 milliseconds, the ErrorEvent is dispatched to nobody and the main-thread promise never settles, so the spinner runs forever. On the supervised track onerror fires at 121 milliseconds, a jittered backoff runs, a replacement worker spawns and hydrates, and results resume at 400 milliseconds — a mean time to recovery of about 280 milliseconds. 0 ms 100 200 300 400 500 ms Unsupervised onerror unbound worker running throw at 120 ms ErrorEvent dispatched to nobody promise never settles · spinner runs forever Supervised onerror + heartbeat onerror at 121 ms respawn HYDRATION_ACK at 400 ms worker running backoff wait spawn + hydrate results resume mean time to recovery ≈ 280 ms
The same crash on two pages: without a bound 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' }) — so import works 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 error and unhandledrejection listeners before any top-level await. 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:48213 and 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 onerror closures. Duplicate recovery paths are the most common bug in this area: onerror and 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-origin plus Cross-Origin-Embedder-Policy: require-corp) and self.crossOriginIsolated evaluates to true.

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.

Supervisor architecture and its message channels The main-thread supervisor holds a lifecycle guard, a job queue with an in-flight map, a heartbeat probe, a retry budget with a backoff timer, and a checkpoint ring. The worker holds the bootstrap error traps, a task runner, a heartbeat responder, a validate boundary and a hydrate handler. Five channels connect them: serialized errors and quarantine reports flowing back to the main thread, jobs and results both ways, PING and PONG, and HYDRATE with its acknowledgement. message channels Main thread · Supervisor LifecycleGuard · state machine idle → starting → running → recovering Job queue + in-flight map re-queued onto the replacement Heartbeat probe PING every 2 s · 2 misses = dead RetryBudget + backoff timer full jitter · cap 10 s · 5 attempts CheckpointRing (3 slots) newest-first hydration source Worker thread bootstrap.js — first import error · unhandledrejection · messageerror Task runner AbortController deadline 8 s Heartbeat responder replies ahead of the task handler validate() boundary bad payload → job fault, not a crash HYDRATE handler assertStateShape → ACK or reject __worker_error__ job / RESULT PING / PONG QUARANTINE HYDRATE / ACK
One supervisor object owns every failure channel for one worker: errors and quarantine reports flow back on dedicated channels, the heartbeat runs independently of the job channel, and hydration is part of the startup handshake rather than an afterthought.

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;
  }
}
Trade-off: drop or throw on an illegal transition

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
  });
});
Trade-off: preventDefault keeps the thread alive — and keeps the bug alive too

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
    });
  };
}
Trade-off: preventDefault costs you the native stack in DevTools

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' });
  }
});
Trade-off: a heartbeat cannot distinguish "hung" from "busy"

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.

Restart timing: instant retry, plain exponential backoff, full jitter Three tracks over eight seconds after four pool workers crash together. Instant retry piles all twenty respawns into the first fifty milliseconds and pins a CPU core. Plain exponential backoff spreads the attempts to 0.25, 0.5, 1, 2 and 4 seconds but fires all four workers on the same tick, producing a thundering-herd spike each time. Full jitter multiplies each ceiling by a random factor, scattering the same twenty respawns across the window with no spike. spawn events over 8 s after four pool workers crash together ×20 Instant retry no delay 20 respawns inside 50 ms — none succeed a crash loop that pins a CPU core ×4 Exponential no jitter all four workers respawn on the same tick every retry is a synchronised spike Full jitter random × ceiling the same 20 respawns, spread out no spike, no herd 0 s 1 2 3 4 5 6 7 8 s
The same twenty respawn attempts under three policies. Only the jittered track avoids both failure shapes: the CPU-pinning tight loop on the top track and the synchronised herd that hits the same failing dependency together on the middle one.
Trade-off: retry budget per worker or per pool

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 });
  }
});
Trade-off: checkpoint frequency versus steady-state cost

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));
});
An AbortSignal is cooperative, not pre-emptive

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.

Checkpoint transport: what survives the crash Three columns follow one checkpoint across the thread boundary and then kill the worker. With structured clone both sides hold an independent copy, so the main-thread copy rehydrates the replacement. With transfer the buffer moves into the worker and the main thread is left detached, so the only copy dies with the thread. With a SharedArrayBuffer both threads view the same memory, which outlives the worker and lets the replacement re-attach without a hydration payload. Structured clone postMessage(checkpoint) Main thread keeps checkpoint A copy Worker independent copy A′ worker crashes State survives main copy rehydrates the next one Transfer postMessage(buf, [buf]) Main thread buffer now detached move Worker sole owner of the buffer worker crashes State is lost the only copy died with it SharedArrayBuffer cross-origin isolated pages only Main thread views the shared region no copy Worker views the same bytes worker crashes Memory outlives it replacement re-attaches, no HYDRATE
A checkpoint is only useful if it is redundant. Cloning is the default precisely because the copy the crash cannot reach is the one that rebuilds the replacement; transfer moves the single copy into the thread most likely to die.

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.

What performance.measure('recovery') actually spans A 600 millisecond trace with a main-thread track and a worker track. The mark recovery:start is set in the fault handler at zero. A 120 millisecond backoff wait runs on the main thread, then the replacement worker evaluates its module for 180 milliseconds. At 300 milliseconds the main thread spends 90 milliseconds in a structuredClone of the checkpoint — a main-thread long task — before posting HYDRATE. The worker validates and returns HYDRATION_ACK at 430 milliseconds, where recovery:ready is marked and the queued jobs are released. 0 100 200 300 400 500 600 ms Main thread recovery:start recovery:ready backoff 120 ms clone queue released structuredClone — a 90 ms long task Worker no worker alive module evaluation validate HYDRATE HYDRATION_ACK performance.measure('recovery') = 430 ms
The measure brackets everything between the fault handler and the hydration acknowledgement — including the clone the main thread pays for. That 90 ms block is the part users feel, and the part a Performance trace will show as a long task while the spinner is still up.

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.

From symptom to handler: a triage tree Starting from "the worker stopped producing results", four questions route to a handler. Did an ErrorEvent fire? If yes, classify it as a load failure or a runtime fault. If no, is a PONG still arriving? If not, the event loop is wedged, so terminate and respawn. If it is, did the replacement hydrate? If not, fall back to an older checkpoint and then cold start. If it did, is the retry budget spent? If yes, open the circuit and surface an unrecoverable error; if no, mark the worker healthy and reset the attempt counter. Worker stopped producing 1 · Did an ErrorEvent fire? on the Worker object yes Classify: load failure or runtime fault load → fix the URL, do not retry · runtime → backoff respawn no 2 · Is a PONG still arriving? within the heartbeat deadline no Event loop is wedged — nothing will fire terminate() after two misses, then respawn with backoff yes 3 · Did the replacement hydrate? HYDRATION_ACK accepted no The checkpoint may be what killed it offer the previous ring slot, then cold-start empty yes 4 · Is the retry budget spent? attempts vs maxAttempts yes Open the circuit — stop respawning move to terminated and surface an unrecoverable error no markHealthy() · reset attempts
Triage runs on what you can observe, not on what you assume happened: an ErrorEvent, a missing PONG, a rejected hydration and an exhausted budget each point at a different handler, and only the first of the four announces itself.

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.

Worker crash and recovery state machine Five states with guarded transitions. IDLE moves to STARTING on start, STARTING to RUNNING on the READY handshake, and RUNNING to RECOVERING on a crash or a missed heartbeat. A startup failure sends STARTING straight to RECOVERING. From RECOVERING a jittered backoff respawns into STARTING, or the retry budget runs out and the supervisor moves to TERMINATED. RUNNING can also be terminated explicitly. A second failure signal for a crash already being handled is dropped by the guard. IDLE no worker yet STARTING awaiting READY RUNNING accepting jobs RECOVERING backoff pending TERMINATED no more retries start() READY crash backoff + jitter, then respawn load or evaluation error max retries terminate() a second failure signal for a crash already being handled is dropped by the guard
The five states and every legal edge between them. Because each edge is guarded, the duplicate signal that normally spawns a second replacement worker — 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.

Frequently Asked Questions

Why don't unhandled promise rejections in workers propagate to the main thread?
Workers run in isolated event loops with their own microtask queue. An unhandled rejection inside a worker fires the worker’s own unhandledrejection event and nothing else — it does not become an ErrorEvent on the Worker object, and window.onunhandledrejection never sees it. You must listen with self.addEventListener('unhandledrejection', …) inside the worker, call event.preventDefault() so the default abort behaviour is suppressed, and forward a serialized copy of event.reason over postMessage.
How do I implement exponential backoff for worker respawning?
Keep a retry counter that increments on each crash and resets only after the replacement worker has stayed healthy for a stability window (30 s is a reasonable default). Compute the delay as Math.min(base * 2 ** attempt, cap) — for example base 250 ms and cap 10 s — then multiply by a random factor between 0 and 1 (full jitter) so a page that lost ten workers at once does not respawn them in lockstep. After a maximum retry count, move to a terminal state and surface an unrecoverable error to the UI rather than looping forever.
What is the right way to serialize an Error object across a thread boundary?
Do not rely on passing Error instances directly: Chrome 98+ and Firefox 93+ can structured-clone them, but Firefox drops stack, custom subclass properties are lost everywhere, and the prototype is flattened to plain Error. Send a plain object instead — { name, message, stack, cause, timestamp } — and rebuild an Error on the receiving side if you need one. A DOMException needs its code and name copied explicitly.
How do I tell a worker that crashed apart from a worker that silently hung?
A crash dispatches an ErrorEvent on the Worker object; a hang dispatches nothing at all, because an infinite loop or a blocking Atomics.wait keeps the thread technically alive. The only reliable detector is a heartbeat: post a PING on a fixed interval and require a PONG within a deadline. Two missed deadlines means the event loop is wedged, and the supervisor should call terminate() and respawn rather than wait.

See also