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.

The six-state worker lifecycle the main thread tracks A worker starts in INITIALIZING, moves to READY on the WORKER_READY handshake, then alternates between ACTIVE and IDLE as tasks are dispatched and settle. A drain signal moves it to DRAINING and, once the drain is acknowledged, to TERMINATED. A separate dashed edge shows worker.terminate() jumping straight from ACTIVE to TERMINATED without draining. INITIALIZING new Worker(url), handlers attach READY handshake done, no task yet ACTIVE task in flight IDLE pending map empty DRAINING dispatch() now rejects TERMINATED isolate destroyed WORKER_READY dispatch() pending → 0 dispatch() DRAIN_AND_CLOSE DRAIN_COMPLETE worker.terminate() — immediate: no unwind, no finally blocks, nothing flushed
The state machine of Step 4, drawn as the main thread sees it. The drain route (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.

Three symptoms that are all the same lifecycle bug Panel one: a worker module parked at a top-level await receives a posted message with no listener attached, so the message task is discarded. Panel two: a bar chart where the count of live worker threads steps up on every route visit because nothing terminates them. Panel three: an IndexedDB write batch sliced by terminate(), with the left half flushed and the right half never written. 1 — the first message vanishes worker module starts evaluating parked at top-level await postMessage dropped no listener attached yet: the message task is discarded Fix — Step 2 buffer first, replay in order 2 — one live worker per visit live worker threads 1 2 3 4 5 route visits no unmount terminates one Fix — Step 7: teardown hook 3 — terminate() cuts a write IndexedDB write batch flushed never written terminate() no finally, no flush, no unwind the next load reads a half-write Fix — Step 6 drain, flush, then terminate One root cause: the worker treated as an object created and destroyed synchronously.
Three unrelated-looking bug reports, one shared cause. Each symptom maps to a transition the main thread never observed — readiness, ownership, and shutdown.

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' } allows import, requires no importScripts, 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 why self.postMessage type-errors in one file and not another.
  • You know where teardown will live. Identify the exact hook — useEffect cleanup, onBeforeUnmount, ngOnDestroy, router beforeLeave — 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 send Cross-Origin-Opener-Policy: same-origin and Cross-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() };
}
Trade-off: lazy spawn vs warm worker

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 });
  }
}
Trade-off: buffering keeps messages, but hides slow boots

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.

Trade-off: handshake round trip vs optimistic posting

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.

Boot sequence: constructed is not ready A sequence diagram with a main-thread lifeline and a worker lifeline. new Worker(url) returns synchronously while the worker fetches its script, creates an isolate and evaluates its module graph. A message posted during that window is queued. Only when the worker posts WORKER_READY does the main thread resolve its Promise and dispatch the first real task. The interval between construction and WORKER_READY is bracketed as the boot window guarded by the readiness timeout. Main thread Worker thread new Worker(url) returns synchronously fetch worker script create isolate + heap compile + evaluate module run setup, then post READY postMessage({ type: 'PARSE' }) queued — the module has not finished postMessage({ type: 'WORKER_READY' }) Promise resolves → worker handle first real task dispatched boot window createReadyWorker() races this window against a timeout: a worker that never posts WORKER_READY is terminated and the Promise rejects.
Construction is synchronous; readiness is not. Everything between the two is the boot window — the interval the Step 3 timeout exists to bound.

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.

Trade-off: explicit states cost code, implicit states cost debugging

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);
Trade-off: slice size against control latency

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
}
Trade-off: the drain timeout is a guess about your slowest task

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.

Forced termination versus the drain protocol Two timelines for the same shutdown. In the top one, worker.terminate() cuts an in-flight IndexedDB write in half and the queued messages behind it are discarded with the isolate. In the bottom one, a DRAIN_AND_CLOSE signal arrives during the task, the task runs to completion, pending writes are flushed, the worker posts DRAIN_COMPLETE and calls self.close(), and only then is terminate() called — with the whole drain interval bracketed by the timeout passed to Promise.race. Force: worker.terminate() during an in-flight task task: writing a batch to IndexedDB queued queued queued discarded with the isolate terminate() no finally blocks, nothing flushed — the store keeps a half-written batch and every pending Promise dangles time Drain: finish, flush, acknowledge, then terminate() drain timeout — Promise.race in-flight task runs to completion DRAIN_AND_CLOSE flush writes DRAIN_COMPLETE self.close() terminate() in-flight work finishes, durable state is flushed, and every pending resolver settles before the isolate goes away time
The same shutdown, twice. The drain path costs one round trip and a bounded wait; the forced path costs whatever the task had not yet written.

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.

Zombie worker risk 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.

COOP / COEP required for SharedArrayBuffer

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.

What each transfer mechanism leaves behind when the worker dies Four data lanes cut by a vertical terminate() line. A structured clone leaves the main-thread copy intact while the worker copy dies. A transferred ArrayBuffer is detached on the main thread and destroyed with the worker, so it must be re-fetched. SharedArrayBuffer pages persist across the cut and can be handed to a replacement worker. A MessagePort loses its far end, and the surviving end must be closed. terminate() while the worker lives after the isolate is destroyed Structured clone one copy each side main-thread copy intact worker copy dies Transferable ownership handed to the worker detached — buffer gone re-fetch or re-derive SharedArrayBuffer one buffer, two mapped views pages persist hand it to a replacement MessagePort a dedicated channel, both ends open far end destroyed close the surviving end Only shared pages outlive the isolate; every other payload has to be re-derived after a restart.
Read the table above row by row against this cut: the transfer mechanism you choose decides what is still in your hands one millisecond after the worker stops existing.

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.

One instrumented worker lifecycle, end to end A horizontal timeline running spawn, fetch and compile, READY, task slices, drain and terminate, with performance marks above it at spawn-start, WORKER_READY, DRAIN_AND_CLOSE and terminated. Heartbeat pings tick underneath the task slices. Four brackets below name the measured intervals: boot from spawn to WORKER_READY, the round-trip floor, slice latency, and drain duration. spawn-start WORKER_READY DRAIN_AND_CLOSE terminated spawn fetch + compile ready task slices drain terminate heartbeat every 1 s boot: spawn → WORKER_READY slice latency round-trip floor drain duration Every interval is a performance.measure() — read the distribution (p95), never the mean. Spawns minus terminations should return to baseline after every navigation.
The four numbers worth watching, and where each one is taken. Marks bracket the phases; heartbeats prove the loop is still turning between them.

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.

Triage: a task that never completed A decision ladder rooted at a task that never completed. If an ErrorEvent fired on the Worker object the isolate crashed, so fail every in-flight task and respawn. If the heartbeat stopped the loop is wedged, so force-terminate and chunk the work. If a messageerror fired the payload failed to clone, so serialize to a plain object. If the reply arrived but the Promise is still pending, the resolver was never settled. task never completed ErrorEvent on the Worker object? the isolate itself is gone yes fail every in-flight task, respawn #failAll, then a fresh worker (Step 4) no heartbeat stopped arriving? the event loop is not turning yes force-terminate, then chunk the work slices that yield to the loop (Step 5) no messageerror instead of message? the payload failed to clone yes serialize to a plain object listen for messageerror on both ends no reply arrived, Promise pending? the resolver was never settled yes settle it in the error path reject pending resolvers (Steps 4 and 6) Still unexplained? The last logged state transition names the phase that died.
Four questions, asked in this order, separate a crashed isolate from a wedged loop, a clone failure and a leaked resolver — each with a different remedy.

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.

Frequently Asked Questions

How does the main thread event loop differ from a worker thread event loop?
The main thread event loop is coupled to rendering: it interleaves tasks, microtasks, requestAnimationFrame callbacks and style/layout/paint at the display refresh rate, so any task longer than a frame budget (~16 ms at 60 Hz) is visible as jank. A worker event loop has no rendering stage at all — it drains its message queue, runs the task, then drains microtasks. Workers have setTimeout, setInterval, queueMicrotask and performance.now(), but no requestAnimationFrame, no DOM, and no synchronous access to window.
What is the safest way to terminate a worker thread without losing work or leaking memory?
Use a two-phase shutdown. First post a drain signal so the worker stops accepting new tasks, finishes what is in flight and replies with an acknowledgement (or calls self.close()). Only then call worker.terminate(), and always behind a timeout so a wedged worker cannot block navigation. In the finally branch, reject every pending Promise resolver you are holding on the main thread — otherwise those chains, and everything they close over, stay reachable forever.
Does a SharedArrayBuffer survive worker termination and recreation?
Yes. SharedArrayBuffer is backed by memory pages that stay alive while any agent still holds a reference, so terminating one worker does not free them. You can hand the same buffer to a replacement worker with postMessage and no reallocation or copy. Two caveats: the document must be cross-origin isolated (Cross-Origin-Opener-Policy: same-origin plus Cross-Origin-Embedder-Policy: require-corp), and any thread parked in Atomics.wait on a lock the terminated worker held will stay parked until its timeout expires — always pass a finite timeout.
Why does my worker ignore the first message I post to it?
Almost always because the message handler is registered after a top-level await or a dynamic import(). Messages posted before the worker script has run are queued and delivered once it finishes, but if your entry module yields at a top-level await, the queued message task can run while no listener is attached yet and the event is simply dropped. Register self.onmessage (or addEventListener('message', …)) in the first statements of the worker entry module, buffer the payloads, and do the async setup afterwards.
How do I debug a worker that silently fails part-way through its lifecycle?
Attach DevTools to the worker context (Chrome: Sources → Threads; Firefox: about:debugging → This Firefox → Workers), then register self.addEventListener('error', …) and self.addEventListener('unhandledrejection', …) at the top of the worker script and forward a serialized { name, message, stack } payload to the main thread. Add messageerror on both ends to catch payloads that failed to clone, and emit a timestamped log at every state transition so the last transition tells you which phase died.

See also