Main Thread vs Worker Thread Lifecycle
A worker is not just “another place to run code” — it is a second execution context with its own birth, readiness, working life and death, and every one of those transitions is asynchronous and observable from the main thread. This guide is a specialisation of Web Workers Architecture & Communication, and it covers the lifecycle in the order you will implement it: what new Worker() actually costs, why a worker that exists is not yet a worker that is ready, how to model the states explicitly so navigation races become errors rather than hangs, how to drain in-flight work before destroying the isolate, and how to verify all of it with real measurements instead of hope.
IDLE → DRAINING → TERMINATED) is the only one that lets in-flight work finish; worker.terminate() and an uncaught ErrorEvent both jump straight to the terminal state from wherever the worker happens to be.The Problem: Three Bugs That Are All Lifecycle Bugs
Lifecycle mistakes rarely announce themselves as lifecycle mistakes. They arrive as three specific, reproducible symptoms.
The first message vanishes. A dashboard spawns a parser worker and immediately posts a 12 MB CSV to it. On most loads the table renders; on a minority of loads the spinner never stops. The worker entry module begins with a top-level await import('./wasm-parser.js'), so its self.onmessage assignment does not execute until that import settles. Messages posted before a worker script starts are queued and delivered afterwards — but once the module has begun executing and yielded at a top-level await, the queued message task can be dispatched with no listener attached, and the event is discarded with no error, no warning, and nothing in DevTools.
Memory climbs one worker per navigation. A single-page app mounts a chart view, spawns a worker in useEffect, and never terminates it in the cleanup function. Each visit to that route leaves a live thread holding its own isolate, its own heap and whatever typed arrays it last received. Ten navigations later the tab is carrying ten idle workers; the leak is invisible in the main-thread heap snapshot because none of it is on the main thread. The workflow for confirming this is in Identifying Memory Leaks in Workers.
Terminate corrupts state. A worker is half-way through writing a batch of parsed records to IndexedDB when the user hits Back. The route’s cleanup calls worker.terminate(), the isolate is destroyed mid-transaction, and the next load reads a partially written store. terminate() is not a request — it is immediate destruction with no unwind, no finally blocks, and no chance to flush.
All three come from treating the worker as an object that is created and destroyed synchronously, when it is really a state machine whose transitions you must observe and drive. The rest of this guide builds that machine.
Prerequisites
Before implementing any of the patterns below, confirm the following:
- Workers are real entry-point chunks, not blob strings. A stable URL is what makes a worker debuggable, cacheable and source-mappable; the bundler configuration is covered in Bundling Module Workers with Vite and webpack, and the trade-offs against generated workers in Inline Workers vs Dedicated Workers.
- You have decided on module vs classic workers.
{ type: 'module' }allowsimport, requires noimportScripts, and needs Firefox 114+ (see the compatibility table below). - A message envelope exists. Every payload in this guide is
{ type, taskId, payload }. If your app posts bare values, adopt an envelope first — the taxonomy is in Message Passing Strategies. - TypeScript is configured for two lib sets. Worker files need
"lib": ["ES2022", "WebWorker"]; main-thread files need"DOM". Mixing them is whyself.postMessagetype-errors in one file and not another. - You know where teardown will live. Identify the exact hook —
useEffectcleanup,onBeforeUnmount,ngOnDestroy, routerbeforeLeave— before you write the spawn code, not after. - DevTools thread switching is familiar. Breakpoints inside workers only pause the worker; see Chrome DevTools Worker Debugging.
- Cross-origin isolation is settled if any part of the lifecycle uses
SharedArrayBuffer: the document must sendCross-Origin-Opener-Policy: same-originandCross-Origin-Embedder-Policy: require-corp.
Implementing the Lifecycle, Step by Step
Seven steps, in dependency order. Each one is a phase transition on the state machine, and each has a cost you are choosing to pay.
Step 1 — Spawn lazily, and measure what the spawn costs
new Worker() returns synchronously, but the work it starts is not free: the browser resolves and fetches the script (or reads it from cache), creates a fresh isolate with its own heap and event loop, then compiles and evaluates the module graph. On desktop with a warm HTTP cache this is typically a few milliseconds; on a mid-tier Android device with a cold cache and a large module graph it can run to tens of milliseconds — enough to matter if you do it during page load, when the main thread is already contended for parse, layout and hydration.
// workerFactory.ts — main thread
export interface SpawnResult {
worker: Worker;
spawnedAt: number;
}
export function spawnWorker(name: string): SpawnResult {
performance.mark(`${name}:spawn-start`);
// new URL(..., import.meta.url) is what lets Vite/webpack emit the worker
// as its own chunk with a stable, source-mapped URL.
const worker = new Worker(new URL('./task.worker.ts', import.meta.url), {
type: 'module',
name, // shows up in DevTools' thread list — always set it
});
return { worker, spawnedAt: performance.now() };
}
Spawning on first use keeps startup cheap but puts the boot cost on the critical path of the first user action. Spawning during idle time (requestIdleCallback, or immediately after the first contentful paint) hides the cost but pays it on every session, including the majority that never trigger the feature. Rule of thumb: warm the worker only for interactions the user is very likely to reach, and never spawn more than one speculatively — a pool that is sized to the workload is the better answer, covered in Worker Pool Management.
Step 2 — Register handlers in the first statements of the worker
This is the fix for the vanished-first-message bug. The rule is mechanical: the very first thing a worker entry module does is attach its listeners and start buffering. Asynchronous setup happens afterwards, and buffered messages are replayed once it completes.
// task.worker.js — worker scope, no top-level await before this block
const pending = [];
let ready = false;
self.addEventListener('message', (event) => {
if (!ready) {
pending.push(event.data); // buffer, never drop
return;
}
handleMessage(event.data);
});
// Uncloneable payloads fire messageerror, not message — always handle both.
self.addEventListener('messageerror', (event) => {
self.postMessage({ type: 'FATAL', reason: 'messageerror', origin: String(event.origin) });
});
self.addEventListener('error', (e) => {
self.postMessage({ type: 'WORKER_ERROR', name: 'Error', message: e.message, stack: e.error?.stack });
});
self.addEventListener('unhandledrejection', (e) => {
self.postMessage({ type: 'WORKER_ERROR', name: 'UnhandledRejection', message: String(e.reason) });
});
// Only NOW do the slow async work.
(async () => {
const { parse } = await import('./wasm-parser.js');
self.parse = parse;
ready = true;
self.postMessage({ type: 'WORKER_READY' });
for (const data of pending.splice(0)) handleMessage(data); // replay in order
})();
function handleMessage(data) {
if (data.type === 'PARSE') {
const rows = self.parse(data.payload);
self.postMessage({ type: 'TASK_COMPLETE', taskId: data.taskId, payload: rows });
}
}
A buffer means nothing is lost, but it also means a worker whose async setup never resolves looks identical to a busy worker — the queue simply grows. Always cap the buffer (a few hundred entries, or a byte budget) and pair it with the readiness timeout in Step 3, so a stuck import surfaces as a rejected Promise rather than unbounded memory growth.
Step 3 — Complete a readiness handshake before dispatching work
“Constructed” and “ready” are different states, and only the worker knows when it has crossed between them. Resolve the worker handle from a Promise that settles on the WORKER_READY frame, with a timeout on the other side of the race.
// createReadyWorker.ts — main thread
export function createReadyWorker(name: string, timeoutMs = 10_000): Promise<Worker> {
return new Promise((resolve, reject) => {
const { worker } = spawnWorker(name);
let settled = false;
const timer = setTimeout(() => {
if (settled) return;
settled = true;
worker.terminate(); // a worker that never booted has nothing to drain
reject(new Error(`Worker "${name}" did not become ready within ${timeoutMs} ms`));
}, timeoutMs);
worker.addEventListener('message', function onReady(e: MessageEvent) {
if (e.data?.type !== 'WORKER_READY' || settled) return;
settled = true;
clearTimeout(timer);
worker.removeEventListener('message', onReady); // do not leak this closure
performance.measure(`${name}:boot`, `${name}:spawn-start`);
resolve(worker);
});
worker.addEventListener('error', (err: ErrorEvent) => {
if (settled) return;
settled = true;
clearTimeout(timer);
worker.terminate();
reject(new Error(`Worker "${name}" failed to boot: ${err.message}`));
});
});
}
Note what the error branch does: an ErrorEvent on the Worker object during boot is fatal by definition, so it terminates and rejects rather than leaving a half-initialised thread in the pool.
The handshake adds one worker→main hop (sub-millisecond for a tiny frame) plus however long setup takes, and it delays the first task by that amount. Optimistic posting — fire immediately and rely on Step 2's buffer — is faster by exactly that delay, but the main thread then has no idea whether the worker is alive, so the failure mode is a hang instead of a rejection. Use the handshake whenever the worker owns user-visible work; skip it only for fire-and-forget workers whose failure you genuinely do not need to observe.
Step 4 — Track lifecycle state explicitly on the main thread
Once more than one thing can happen (a task in flight, a route change, an error, a shutdown), implicit state becomes a race. A small state machine turns those races into explicit, throwable errors.
// WorkerStateManager.ts — main thread
export type WorkerState =
| 'INITIALIZING' | 'READY' | 'ACTIVE' | 'IDLE' | 'DRAINING' | 'TERMINATED';
interface Resolver { resolve: (v: unknown) => void; reject: (e: Error) => void }
export class WorkerStateManager {
state: WorkerState = 'INITIALIZING';
readonly pendingTasks = new Map<string, Resolver>();
constructor(readonly worker: Worker, readonly name: string) {
this.#listen();
}
#listen(): void {
this.worker.addEventListener('message', (e: MessageEvent) => {
const { type, taskId, payload, error } = e.data ?? {};
if (type === 'WORKER_READY') return this.#transition('READY');
if (type === 'TASK_COMPLETE' || type === 'TASK_ERROR') {
this.#settle(taskId, payload, type === 'TASK_ERROR' ? error : null);
if (this.pendingTasks.size === 0 && this.state === 'ACTIVE') this.#transition('IDLE');
}
});
// Fatal: the isolate is gone or unusable — fail every in-flight task.
this.worker.addEventListener('error', (err: ErrorEvent) => {
this.#failAll(new Error(`Worker "${this.name}" crashed: ${err.message}`));
this.#transition('TERMINATED');
});
}
#transition(next: WorkerState): void {
if (this.state === 'TERMINATED') return; // terminal state absorbs everything
console.debug(`[worker:${this.name}] ${this.state} → ${next}`);
this.state = next;
}
#settle(taskId: string, result: unknown, error: string | null): void {
const resolver = this.pendingTasks.get(taskId);
if (!resolver) return;
this.pendingTasks.delete(taskId);
error ? resolver.reject(new Error(error)) : resolver.resolve(result);
}
#failAll(reason: Error): void {
for (const [taskId, resolver] of this.pendingTasks) {
resolver.reject(reason);
this.pendingTasks.delete(taskId);
}
}
dispatch<T>(type: string, payload: unknown, transfer: Transferable[] = []): Promise<T> {
if (this.state === 'TERMINATED' || this.state === 'DRAINING') {
return Promise.reject(new Error(`Cannot dispatch to worker in state ${this.state}`));
}
this.#transition('ACTIVE');
const taskId = crypto.randomUUID();
return new Promise<T>((resolve, reject) => {
this.pendingTasks.set(taskId, { resolve: resolve as Resolver['resolve'], reject });
this.worker.postMessage({ type, taskId, payload }, transfer);
});
}
}
Two details carry most of the value. TERMINATED absorbs every later transition, so a late-arriving message from a worker that is already gone cannot resurrect the state. And #failAll exists because an unsettled Promise is a permanent leak: it holds its resolver, its .then chain, and every closure and component reference reachable from them.
Six states and a transition log add perhaps 80 lines you would not otherwise write. What you buy is that "dispatched to a draining worker" becomes a rejected Promise with a state name in the message, instead of a task that is accepted, never answered, and never explained. The transition log is also the single most useful artefact when a lifecycle bug reaches production — the last transition before silence names the phase that died.
Step 5 — Keep the worker loop responsive: chunking and heartbeats
A worker thread cannot jank the UI, but it can absolutely wedge itself. A single 4-second synchronous loop makes the worker deaf: drain signals, cancellations and heartbeats all sit in the queue behind it, because a worker event loop is still one loop. Chunk long computations and yield between slices.
// chunked.worker.js — worker scope
let cancelled = false;
self.addEventListener('message', (e) => {
if (e.data.type === 'CANCEL') cancelled = true;
});
async function processInChunks(rows, taskId, sliceMs = 12) {
let i = 0;
while (i < rows.length) {
const deadline = performance.now() + sliceMs;
// Work until the slice budget is spent, then hand the loop back.
while (i < rows.length && performance.now() < deadline) {
transformRow(rows[i++]);
}
if (cancelled) {
self.postMessage({ type: 'TASK_ERROR', taskId, error: 'cancelled' });
return;
}
self.postMessage({ type: 'TASK_PROGRESS', taskId, done: i, total: rows.length });
// A zero-delay timer still yields; nested timers are clamped to 4 ms
// after five levels, so keep slices well above that floor.
await new Promise((r) => setTimeout(r, 0));
}
self.postMessage({ type: 'TASK_COMPLETE', taskId });
}
// Heartbeat: proves the loop is turning, independent of task progress.
setInterval(() => self.postMessage({ type: 'HEARTBEAT', t: Date.now() }), 1000);
Every yield costs one event-loop turn plus a postMessage hop. A 12 ms slice over a 3-second job means roughly 250 yields — negligible against the work itself — and bounds worst-case cancellation latency to about 12 ms. Drop to 1 ms slices and the yield overhead starts to dominate; go to 200 ms slices and a cancel or drain can sit unanswered for a fifth of a second. Slices in the 5–20 ms range are the usual sweet spot, and are best set from a measurement of your own per-row cost rather than a fixed row count.
Step 6 — Drain before you terminate
worker.terminate() destroys the isolate immediately: the currently executing task is cut off wherever it happens to be, finally blocks do not run, queued messages are discarded, and nothing is flushed. self.close() is the cooperative counterpart — the worker stops accepting new messages and discards its queue, but the currently running script finishes. A safe shutdown uses the second to reach the first.
// gracefulTerminate.ts — main thread
export async function gracefulTerminate(
mgr: WorkerStateManager,
timeoutMs = 5_000,
): Promise<'drained' | 'forced'> {
if (mgr.state === 'TERMINATED') return 'drained';
mgr.state = 'DRAINING'; // dispatch() now rejects — no new work gets in
const drained = new Promise<void>((resolve) => {
const onDrain = (e: MessageEvent) => {
if (e.data?.type !== 'DRAIN_COMPLETE') return;
mgr.worker.removeEventListener('message', onDrain);
resolve();
};
mgr.worker.addEventListener('message', onDrain);
});
const timedOut = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('drain timeout')), timeoutMs),
);
mgr.worker.postMessage({ type: 'DRAIN_AND_CLOSE' });
try {
await Promise.race([drained, timedOut]);
return 'drained';
} catch {
console.warn(`[worker:${mgr.name}] drain timed out — forcing termination`);
return 'forced';
} finally {
// Whatever happened, no task may stay pending: an unsettled Promise
// pins its resolver and every closure reachable from it.
for (const [taskId, resolver] of mgr.pendingTasks) {
resolver.reject(new Error('worker terminated during shutdown'));
mgr.pendingTasks.delete(taskId);
}
mgr.worker.terminate();
mgr.state = 'TERMINATED';
}
}
The worker side of the protocol is four lines: stop accepting work, finish what is in flight, acknowledge, close.
// drain handling inside the worker
if (data.type === 'DRAIN_AND_CLOSE') {
accepting = false;
await inFlight; // whatever is running now
await flushPendingWrites(); // IndexedDB, caches, telemetry
self.postMessage({ type: 'DRAIN_COMPLETE' });
self.close(); // refuse further messages
}
Too short and you force-kill work that was about to finish; too long and a wedged worker delays navigation by seconds. Set the timeout from a measured p99 of your longest task, not a round number, and make it cheap to hit: with Step 5's chunking, a drain is answered within one slice rather than at the end of the whole job. On pagehide the browser may not give you the full window at all, so flush the durable state first and treat the acknowledgement as best-effort.
Step 7 — Wire teardown into the framework lifecycle
A worker is owned by whatever created it, and in an SPA that owner is usually a component that will unmount long before the tab closes. Teardown belongs in the same hook that owns the spawn.
// useWorker.ts — React; the same shape maps to onBeforeUnmount / ngOnDestroy
import { useEffect, useRef } from 'react';
export function useTaskWorker(): React.MutableRefObject<WorkerStateManager | null> {
const ref = useRef<WorkerStateManager | null>(null);
useEffect(() => {
let disposed = false;
createReadyWorker('csv-parser').then((worker) => {
if (disposed) return worker.terminate(); // unmounted during boot
ref.current = new WorkerStateManager(worker, 'csv-parser');
});
// The tab can go away without unmounting anything.
const onPageHide = () => { if (ref.current) void gracefulTerminate(ref.current, 300); };
addEventListener('pagehide', onPageHide);
return () => {
disposed = true;
removeEventListener('pagehide', onPageHide);
if (ref.current) void gracefulTerminate(ref.current);
ref.current = null;
};
}, []);
return ref;
}
The disposed flag matters more than it looks: a route change during the boot window leaves a worker that finishes initialising with nobody holding it, and no later hook will ever run for it. That is a zombie created by a fast user rather than by missing cleanup. Framework-specific variants — router guards, Suspense boundaries, StrictMode’s double-invoked effects — are worked through in Handling Worker Termination Gracefully in SPAs.
React, Vue and Angular components unmount during route transitions, but any worker they spawned keeps running unless it is explicitly torn down — it is referenced by the browser, not by your component tree, so garbage collection will never reclaim it for you. Always drain-and-terminate inside useEffect cleanup, onBeforeUnmount or ngOnDestroy, and handle the unmount-during-boot case above. Missing this is the single most common source of background-thread memory growth in SPAs.
Data Transfer Across the Lifecycle: Clone, Transfer or Share
The transfer mechanism you pick is a lifecycle decision as much as a performance one, because each mechanism behaves differently when a worker dies mid-task.
| Mechanism | Cost | What happens if the worker is terminated mid-task |
|---|---|---|
| Structured clone | Copies the whole graph; a 10 MB typed array costs roughly 12–18 ms and doubles peak memory during the copy | The main thread’s copy is untouched; the worker’s copy dies with the isolate. Safest, most expensive |
Transferable ArrayBuffer / ImageBitmap / MessagePort |
Ownership moves, near-zero copy regardless of size | The buffer is gone: detached on the main thread and destroyed with the worker. Re-fetch or re-derive it |
SharedArrayBuffer |
No copy, no ownership change; both agents map the same pages | Memory survives — the buffer outlives the worker and can be handed straight to a replacement |
MessagePort (dedicated channel) |
One transferable at setup, then independent queueing | The port’s other end is destroyed; call port.close() on the surviving side or the channel stays reachable |
That third row is the one worth designing around. Because a SharedArrayBuffer is backed by memory pages that live as long as any agent references them, a crashed or terminated worker does not take the data with it: spawn a replacement and post it the same buffer, with no reallocation and no copy. That makes shared memory the natural choice for long-lived state that must survive worker restarts — a ring buffer of samples, a decoded frame pool, an index being incrementally built. The lock-free patterns for it are in SharedArrayBuffer & Atomics; the ownership semantics of the transferable row are in Transferable Objects & Zero-Copy.
The catch is synchronisation state, which does not survive. If the terminated worker held a lock, or was the thread that was going to call Atomics.notify, every other thread parked in Atomics.wait on that address stays parked. Nothing unblocks them, and no exception is thrown. Always pass a finite timeout to Atomics.wait, treat a "timed-out" result as “the peer may be dead”, and re-validate the shared structure before continuing.
To share memory across worker threads, the document must be served with Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp. Without cross-origin isolation, SharedArrayBuffer is undefined at runtime — check self.crossOriginIsolated before you branch on it. These headers restrict third-party iframes, ad tags and unadorned cross-origin images, but they are what unlocks Atomics for lock-free synchronisation.
Verification & Measurement
Every phase in this lifecycle is measurable, and none of the numbers are portable — measure them on your own payloads and your own target device.
Boot cost. The marks in Step 1 and the performance.measure in Step 3 give you a worker:boot entry per spawn. Read them back and watch the distribution, not the mean:
const boots = performance.getEntriesByName('csv-parser:boot', 'measure');
const durations = boots.map((e) => e.duration).sort((a, b) => a - b);
const p95 = durations[Math.floor(durations.length * 0.95)];
console.info(`worker boot p95: ${p95.toFixed(1)} ms over ${durations.length} spawns`);
If p95 boot is a meaningful fraction of your interaction budget, the answer is a pooled, pre-warmed worker rather than a faster boot — see Worker Pool Management.
Round-trip and clone cost. Time a no-op echo task to isolate the messaging floor from the work: post { type: 'PING' } and measure until the reply. That gives you the fixed per-hop cost. Then post your real payload and subtract — the difference is serialization, and it scales with payload size. The method for attributing that cost precisely is in postMessage Bottleneck Analysis.
Drain duration. Wrap gracefulTerminate in marks and record how often it returns 'forced' rather than 'drained'. A forced rate above a fraction of a percent means your timeout is wrong or your tasks are not chunked.
Liveness. Count spawns minus terminations in a module-level counter and log it on route change; the number should return to its baseline after every navigation. Independently, track heartbeat gaps: three consecutive misses at a 1-second interval means the worker is wedged in a synchronous block, and the only remedy is force-terminate and respawn.
Thread-level inspection. In Chrome, the Performance panel gives each worker its own track — record a navigation and you can see the worker being created, its script compiling, and (if you have the bug) the previous worker’s track still ticking after the route changed. Chrome’s Sources → Threads panel and Firefox’s about:debugging worker list let you pause inside a specific context; the workflows are in Chrome DevTools Worker Debugging and Firefox Worker Debugging. For heap growth, take snapshots with the worker context selected — a main-thread-only snapshot will show nothing.
Failure Modes & Error Handling
Errors in a worker do not reach window.onerror. An uncaught exception fires an ErrorEvent on the Worker object on the main thread; an unhandled rejection fires only on the worker’s own self; a payload that fails to deserialize fires messageerror rather than message. Handle all three, on both sides, or a whole class of failure is invisible.
| Failure | Cause | Fix |
|---|---|---|
| First message ignored | Handler registered after a top-level await or dynamic import() |
Attach listeners and buffer in the first statements of the entry module (Step 2) |
| Promise never settles | Worker crashed; the task’s resolver is still in the map | Reject every pending resolver in the error handler and on shutdown (Steps 4 and 6) |
| Memory grows per navigation | Component unmounted, worker never terminated | Drain-and-terminate in the cleanup hook, including the unmount-during-boot path (Step 7) |
| Half-written IndexedDB / cache state | terminate() called mid-transaction |
Flush inside the drain handler before self.close() (Step 6) |
| Drain never acknowledged | A synchronous block is starving the worker’s event loop | Chunk the computation so the loop turns between slices (Step 5) |
messageerror on either side |
Payload contains a function, DOM node, or other uncloneable value | Serialize to a plain object first; listen for messageerror so it fails loudly |
Detached-buffer TypeError after retry |
The task’s ArrayBuffer was transferred, so the retry has nothing to send |
Keep a re-derivable source, or clone instead of transferring on paths you may retry |
| Waiters hang after a worker dies | The terminated worker owned the lock or the pending Atomics.notify |
Always pass a timeout to Atomics.wait; re-validate shared state after a "timed-out" result |
"Script error." with no detail |
Worker script served cross-origin without CORS, so the event is sanitized | Serve worker bundles same-origin, or add Access-Control-Allow-Origin |
| Errors invisible in production | No transport for worker errors to the telemetry backend | Forward serialized errors over the message channel — see Production Error Telemetry |
Retry policy follows from the state machine rather than from the error itself. A failure during INITIALIZING is a boot failure: terminate, and retry the spawn with backoff, because a worker that never booted has nothing to preserve. A failure in ACTIVE is a task failure: reject that task, keep the worker if the error event did not fire, and respawn if it did. A failure in DRAINING is not really a failure — force-terminate and move on, since by then you have already decided the worker is going away. Restart, backoff and checkpoint-rehydration policy is developed further in Error Handling & Crash Recovery.
Browser Compatibility
| Feature | Chrome | Firefox | Safari | Edge |
|---|---|---|---|---|
Worker constructor |
4+ | 3.5+ | 4+ | 12+ |
worker.terminate() |
4+ | 3.5+ | 4+ | 12+ |
self.close() |
4+ | 3.5+ | 4+ | 12+ |
Module workers (type: 'module') |
80+ | 114+ | 15+ | 80+ |
name option on Worker |
70+ | 55+ | 12+ | 79+ |
messageerror event |
60+ | 57+ | 12+ | 79+ |
unhandledrejection in WorkerGlobalScope |
49+ | 69+ | 11.1+ | 79+ |
MessageChannel in workers |
4+ | 41+ | 5+ | 12+ |
SharedArrayBuffer (cross-origin isolated) |
68+ | 79+ | 15.2+ | 79+ |
Atomics.waitAsync() |
87+ | 103+ | 15.2+ | 87+ |
crypto.randomUUID() in workers |
92+ | 95+ | 15.4+ | 92+ |
Two rows change how you write the code. Module workers only reached Firefox 114, so if you must support older Firefox builds your bundler has to emit a classic-worker fallback that uses importScripts — the configuration is in Bundling Module Workers with Vite and webpack. And Atomics.waitAsync is the newest API in the table: on the main thread it is the only legal way to wait on shared memory at all, since blocking Atomics.wait throws a TypeError there by design.
Going Further
A worker can also end without being asked to. An uncaught exception announces itself, an out-of-memory kill announces nothing at all, and a deadlocked thread stays alive while answering nothing — so recovery needs a supervisor rather than an error handler. Restarting Crashed Workers with Exponential Backoff builds one, including the circuit breaker that stops a permanently broken worker from restarting forever.