Handling Worker Termination Gracefully in SPAs

In a single-page app the component that owns a worker disappears on a route change, but the worker does not — and worker.terminate(), the obvious fix, destroys the thread mid-task with no unwind, no flush and no chance for pending writes to complete.

This page is the narrow teardown case under Main Thread vs Worker Thread Lifecycle, the parent guide covering the full spawn-to-death sequence, which itself sits inside the Web Workers Architecture & Communication reference. The lifecycle states, the readiness handshake and the message envelope ({ type, taskId, payload }) are defined there and assumed here. What follows is only the last transition: getting from a running worker to a dead one, during a client-side navigation, without losing data or leaking a thread.

The Failure You Are Fixing

Three symptoms, one cause.

A worker is half-way through writing parsed records to IndexedDB when the user hits Back. The route’s cleanup calls terminate(), the isolate is destroyed mid-transaction, and the next visit reads a partially written store. terminate() is not a request — it is immediate destruction. finally blocks do not run, queued messages are discarded, and an open fetch the worker started is dropped in a half-open state rather than aborted.

The mirror-image bug is the route that never calls terminate() at all. Each visit spawns a thread that nothing ever kills, and ten navigations later the tab is carrying ten idle workers, each with its own isolate and whatever typed arrays it last received. None of it appears in a main-thread heap snapshot, which is why it usually ships.

The third is the subtlest: teardown that does call terminate() and still leaks, because the main thread is holding a Promise resolver for a task that will now never be answered. The isolate is gone; the resolver, its continuation chain and every component reference reachable from it are not.

Three teardown symptoms, one cause Panel one: an IndexedDB transaction bar cut in two by terminate(), with the left half committed and the right half never written. Panel two: a route timeline where the count of live worker threads steps up by one on every visit because no unmount ever terminates a worker. Panel three: a worker isolate marked destroyed while the main thread still holds a pending Promise resolver, its continuation chain and the component tree, all enclosed as the retained set. 1 — the half-written store terminate() inside a transaction IndexedDB batch, 5 000 rows committed never written terminate() no finally blocks run queued messages discarded open fetch left half-open the next visit reads a partial store 2 — a thread per visit cleanup never calls terminate() live worker threads every mount spawns one; no unmount kills one 1 2 3 4 /list /detail /list /detail invisible in a main-thread snapshot 3 — dead isolate, live refs terminate() ran; memory did not drop worker isolate destroyed by terminate() the main thread still holds: pending Promise resolver its continuation chain the whole component tree the retained set
Three bugs that look unrelated in a bug tracker. All three are the same mistake: treating a worker as an object that dies when its owner does.

Minimal Reproducible Example

The protocol is four frames: the main thread posts DRAIN_AND_CLOSE, the worker stops accepting work and finishes what it has, the worker posts DRAIN_COMPLETE and calls self.close(), the main thread calls terminate(). A timeout guarantees the last step happens whether or not the middle two do.

Worker side — stop accepting, abort, flush, acknowledge, close:

// task.worker.js — worker scope (classic or module; nothing here needs `import`)
let accepting = true;
let inFlight = Promise.resolve();
const controllers = new Set(); // AbortControllers for network work in progress

self.addEventListener('message', (event) => {
  const { type, taskId, payload } = event.data;

  if (type === 'DRAIN_AND_CLOSE') {
    accepting = false;                     // later tasks are refused, not queued
    for (const c of controllers) c.abort(); // half-open fetches become AbortErrors

    inFlight
      .catch(() => {})                     // a failed task must not block the drain
      .then(flushPendingWrites)            // IndexedDB, Cache API, telemetry beacons
      .then(() => {
        self.postMessage({ type: 'DRAIN_COMPLETE' });
        self.close(); // discards the queue; the message above is already posted
      });
    return;
  }

  if (!accepting) return; // arrived after the drain signal — never start new work

  inFlight = run(type, taskId, payload);
});

async function run(type, taskId, payload) {
  const controller = new AbortController();
  controllers.add(controller);
  try {
    const rows = await parseChunks(payload, controller.signal);
    if (!accepting) return;                // drained while we were awaiting
    // Hand the buffer back zero-copy rather than cloning it on the way out.
    self.postMessage({ type: 'TASK_COMPLETE', taskId, payload: rows }, [rows.buffer]);
  } catch (err) {
    if (controller.signal.aborted) return; // cancellation is not an error
    self.postMessage({ type: 'TASK_ERROR', taskId, error: String(err) });
  } finally {
    controllers.delete(controller);
  }
}

Main thread — acknowledge or time out, then terminate exactly once:

// disposeWorker.ts — main thread
export type DisposeResult = 'drained' | 'timeout';

interface Resolver { reject: (e: Error) => void }

export function disposeWorker(
  worker: Worker,
  pending: Map<string, Resolver> = new Map(),
  timeoutMs = 300,
): Promise<DisposeResult> {
  return new Promise<DisposeResult>((resolve) => {
    let timer: number | undefined;
    let settled = false;

    const finish = (result: DisposeResult): void => {
      if (settled) return;                 // ack and timeout can both fire
      settled = true;
      clearTimeout(timer);
      worker.removeEventListener('message', onMessage);

      // Nothing may stay pending: an unsettled Promise pins its whole chain.
      for (const [taskId, resolver] of pending) {
        resolver.reject(new Error(`worker disposed before task ${taskId} completed`));
        pending.delete(taskId);
      }

      worker.terminate();                  // no-op if self.close() already ran
      resolve(result);
    };

    const onMessage = (event: MessageEvent): void => {
      if (event.data?.type === 'DRAIN_COMPLETE') finish('drained');
    };

    worker.addEventListener('message', onMessage);
    timer = window.setTimeout(() => finish('timeout'), timeoutMs);
    worker.postMessage({ type: 'DRAIN_AND_CLOSE' });
  });
}
The same shutdown as two message sequences Top timeline: the main thread dispatches task 7, the worker begins writing a batch to IndexedDB, and worker.terminate() cuts the lane in half — after the cut the task resolver never settles on the main thread and the queued messages are discarded with the isolate. Bottom timeline: cleanup drops the reference and posts DRAIN_AND_CLOSE, the worker aborts its fetches, finishes the in-flight task, runs flushPendingWrites, posts DRAIN_COMPLETE and calls self.close, while the main thread paints the next route, settles every pending resolver and only then calls terminate. A dashed bracket marks the 300 millisecond budget that ends when the acknowledgement arrives. Force — terminate() while a task is running worker.terminate() main thread worker dispatch(task 7) postMessage task 7 — writing a batch to IndexedDB task 7 resolver never settles closures stay reachable queued messages discarded isolate gone time No finally blocks, no flush: the store keeps a half-written batch. Drain — signal, finish, flush, acknowledge, then terminate() 300 ms budget — the ack arrives first main thread worker cleanup: drop ref, fire disposal DRAIN_AND_CLOSE abort fetches in-flight task finishes flushPendingWrites DRAIN_COMPLETE self.close() next route paints settle pending terminate() time The drain runs on the worker’s own thread — the navigation never waits on it. If the ack never comes, the timer fires at 300 ms and terminate() happens anyway.
The forced path ends the isolate mid-write and leaves the main thread holding the bag. The drain path costs one round trip, bounded by a timeout that fires only when the worker is wedged.

Line-by-Line Walkthrough

accepting = false before anything else. The flag is set synchronously, in the same task that received the drain signal, so any message already sitting in the queue behind it is refused rather than started. Without it, a task dispatched microseconds before the route change begins its work after you decided to shut down, and the drain waits for it.

controllers.abort() is what makes teardown fast. A worker waiting on a slow fetch cannot acknowledge anything for as long as the request takes. Aborting turns those requests into AbortError rejections immediately, which is also the only clean way to close a half-open connection — terminate() drops the socket without any of that.

inFlight.catch(() => {}) before the flush. The drain must proceed even when the task it is waiting for rejects. Swallowing here is deliberate: the task’s own catch in run() has already reported the failure through TASK_ERROR, and re-throwing at this point would skip the flush and the acknowledgement.

postMessage before self.close(), in that order. self.close() empties the worker’s incoming queue and stops it accepting messages, but a frame already handed to postMessage is on its way out and is still delivered. Reverse the two lines and the main thread never hears the acknowledgement — it waits out the full timeout on every navigation.

The second if (!accepting) return inside run(). Every await is a place the drain signal can arrive. Re-checking after the await stops the worker posting a TASK_COMPLETE for a route the user has already left, and — because that post transfers rows.buffer — stops it detaching a buffer nobody is listening for. The ownership rules behind that transfer are in Transferable Objects & Zero-Copy.

settled guards a double finish. The acknowledgement and the timeout are genuinely racing; both call finish, and terminate() on an already-terminated worker is harmless, but rejecting the same resolver twice or resolving the outer Promise twice is a bug waiting to be read as one.

The pending loop is the part people omit. Terminating the isolate does not settle the Promises the main thread handed out for tasks in flight. Rejecting them is what actually releases the closures — this is the difference between a route change that frees memory and one that only looks like it does.

Where ordering actually matters Left: the worker’s inbox at the moment DRAIN_AND_CLOSE is handled. That frame sets accepting to false, the two task frames queued behind it are refused rather than started, and anything still in the queue is discarded by self.close. DRAIN_COMPLETE is posted before self.close is called, so it is already in flight and is still delivered. Right: the task function, showing the await on parseChunks as the point where the drain signal can land, and the second accepting check immediately after it that stops a stale TASK_COMPLETE and a pointless buffer transfer. The worker inbox when the drain lands DRAIN_AND_CLOSE accepting = false TASK #12 refused, not queued TASK #13 refused, not queued anything still here: emptied by self.close() postMessage(DRAIN_COMPLETE) posted first, so it is already in flight and still delivered Reverse those two lines and the ack never arrives: every navigation waits out the full timeout instead. Two checkpoints inside run() controllers.add(controller) await parseChunks(payload, signal) the drain signal can land during this await if (!accepting) return; second checkpoint — guards the post below postMessage(TASK_COMPLETE, [rows.buffer]) Skip the second check and the worker posts a result for a route the user has left — and detaches rows.buffer for nobody.
Two orderings carry the whole protocol: refuse before you flush, and acknowledge before you close.

Wiring It Into the Router

Disposal belongs in the same hook that owns the spawn, and it must not be awaited on the navigation path.

// useTaskWorker.ts — React 18+; the same shape maps to Vue and Angular
import { useEffect, useRef } from 'react';

export function useTaskWorker() {
  const ref = useRef<Worker | null>(null);

  useEffect(() => {
    let disposed = false;
    const worker = new Worker(new URL('./task.worker.js', import.meta.url), {
      type: 'module',
      name: 'csv-parser', // labels the thread in DevTools — always set it
    });
    ref.current = worker;

    // The tab can go away without unmounting anything. Skip bfcache-eligible
    // hides: the page may be restored with this worker still usable.
    const onPageHide = (e: PageTransitionEvent) => {
      if (e.persisted || disposed) return;
      disposed = true;
      void disposeWorker(worker, undefined, 100); // shorter budget: no window here
    };
    addEventListener('pagehide', onPageHide);

    return () => {
      removeEventListener('pagehide', onPageHide);
      if (disposed) return;
      disposed = true;
      ref.current = null;        // drop the reference synchronously
      void disposeWorker(worker); // do NOT await — the next route renders now
    };
  }, []);

  return ref;
}

The equivalents are mechanical: Vue 3 calls onBeforeUnmount(() => { void disposeWorker(worker); }) inside setup, and Angular implements ngOnDestroy(): void { void disposeWorker(this.worker); }. Router-level guards (beforeLeave, canDeactivate) are the wrong place for this — they run before the navigation is committed, so an awaited drain there becomes latency the user sees.

Two rules survive every framework. Drop the reference synchronously, so the next route’s render never touches a worker that is on its way out. And never register beforeunload for teardown: it makes the page ineligible for the back/forward cache in every browser, which costs far more than the teardown saves.

The three exits from a worker-owning component A component that spawns a worker in its mount effect has three exits. On unmount during a route change it drops the reference and calls disposeWorker without awaiting, so the next route paints immediately. On a pagehide event where persisted is true the page is entering the back/forward cache, so the worker is left frozen and is reused on a back navigation. On a pagehide where persisted is false the worker is disposed with a shorter hundred-millisecond budget and the acknowledgement is treated as best-effort. Two wrong places are marked below: router guards such as beforeLeave or canDeactivate, which run before the navigation commits, and a beforeunload listener, which makes the page ineligible for the back/forward cache. the component owns it spawned in the mount effect unmount — route change drop the reference, then disposeWorker(worker) never awaited next route paints immediately pagehide, persisted = true do nothing at all the page is entering bfcache and its workers freeze with it resumed on Back, nothing respawned pagehide, persisted = false disposeWorker(worker, 100) no reliable async window here: the ack is best-effort durable state flushed in the worker first Wrong places for teardown router guard: beforeLeave / canDeactivate runs pre-commit — an awaited drain is latency the user sees a beforeunload listener makes the page ineligible for the back/forward cache
Teardown has exactly three legitimate triggers, and persisted is the one flag that decides whether a pagehide is a shutdown or a pause.

Verifying No Thread Survived the Navigation

Worker counts are not exposed by any standard API — navigator.serviceWorker.controller reports the active service worker and tells you nothing about dedicated workers. Heap snapshots are the authoritative source:

  1. Open DevTools → Memory, and take a heap snapshot while sitting on the route that owns the worker.
  2. Filter the class list for DedicatedWorkerGlobalScope to see the live and detached scopes.
  3. Navigate away, force a garbage collection, and take a second snapshot.
  4. Switch to the Comparison view and look for DedicatedWorkerGlobalScope deltas that never go negative.
  5. Inspect retained MessagePort objects — an unclosed port keeps the surrounding scope reachable even after the worker is gone.

The full diffing workflow, including how to read the retainer chain back to the component that created the leak, is in Heap Snapshot Diffing for Worker Leaks, and the thread-switching basics are in Chrome DevTools Worker Debugging. In CI, a Playwright test that navigates back and forth twenty times and then asserts on the snapshot’s scope count catches regressions the first time someone deletes a cleanup function.

What to measure Target How
Retained worker scopes after navigation 0 DedicatedWorkerGlobalScope count in the snapshot comparison
Disposal outcome 'drained' on the large majority of route changes Count 'timeout' returns from disposeWorker in your metrics
Drain acknowledgement latency Within roughly one task slice performance.now() delta between the drain post and DRAIN_COMPLETE
Main-thread cost of teardown Under one frame Performance panel recording across the route transition
Reading the snapshot comparison for a retained worker Two snapshots are compared: one taken on the route that owns the worker, one taken after navigating away and forcing a garbage collection. In the healthy run the DedicatedWorkerGlobalScope row shows one deleted object and a delta of minus one, and MessagePort shows minus two. In the leaking run the same constructor shows nothing new, nothing deleted and a delta of zero, meaning the scope is still retained. Beneath the table, the retainer chain for that row runs from the component instance to an onMessage closure that was never removed, to the Worker object, to the worker scope that cannot be collected. Snapshot 1 — taken on /report worker running, one live scope Snapshot 2 — after navigating away taken after a forced garbage collection Run Constructor # New # Deleted Delta Verdict healthy DedicatedWorkerGlobalScope 0 1 −1 freed healthy MessagePort 0 2 −2 freed leaking DedicatedWorkerGlobalScope 0 0 0 retained Why the last row never drops — the retainer chain under it ReportView component instance retains onMessage closure never removed retains Worker object reference still held retains DedicatedWorkerGlobalScope cannot be collected Removing the listener and settling the pending resolvers breaks the chain at the second link.
The comparison view answers one question: did the scope count go down. A delta of zero after a forced GC means something on the main thread is still pointing at it.

Gotchas and Edge Cases

React StrictMode disposes the worker you just created. In development, React 18 and later mount, unmount and remount every effect once to surface missing cleanup. Your useEffect therefore spawns a worker, immediately disposes it, and spawns another — which is correct behaviour and proves the cleanup works, but it doubles boot cost in dev and produces confusing paired log lines. What it must not do is leave the first worker alive: if the spawn is asynchronous (a readiness handshake, a dynamic import), the cleanup can run before the worker exists, so keep a disposed flag and dispose in the resolution path when it is already set. That case — a route change during the boot window — is the one zombie that a fast user creates rather than missing code.

pagehide is not a drain window, and persisted changes the answer. When a page enters the back/forward cache, its workers are frozen rather than destroyed, and a back navigation resumes them; tearing down on a persisted hide throws away a thread that was about to be reused. When the page is genuinely going away, you get no reliable asynchronous window at all — the acknowledgement may never arrive. Flush durable state inside the worker first, treat the acknowledgement as best-effort, and use a much shorter budget than on a route change.

A transferred buffer cannot be retried. If the task’s payload was handed to the worker as a transferable, the main thread’s ArrayBuffer is detached — length zero — the moment postMessage returns. Force-terminate that task and there is nothing to resend: any retry throws a TypeError on the detached buffer. On paths you may retry, keep a re-derivable source (the original response, the file handle) or clone rather than transfer, and accept the copy cost as the price of retryability.

Hot module replacement multiplies workers in development. Every HMR update re-evaluates the module that constructs the worker, and the previous instance keeps running because nothing disposed it — a long dev session accumulates dozens of threads and profiles become unreadable. Hold the worker in a module-level singleton and dispose it from the HMR hook:

// worker-singleton.js
let instance = null;

export function getWorker() {
  instance ??= new Worker(new URL('./task.worker.js', import.meta.url), { type: 'module' });
  return instance;
}

export async function destroyWorker() {
  if (!instance) return;
  await disposeWorker(instance);
  instance = null;
}

if (import.meta.hot) {
  import.meta.hot.dispose(() => destroyWorker()); // webpack: module.hot.dispose
}

The bundler configuration that makes new URL(…, import.meta.url) emit a real worker chunk — rather than a broken path or an inlined blob — is covered in Bundling Module Workers with Vite and webpack.

Four edge cases the drain protocol does not cover on its own Tile one: React StrictMode spawns, disposes and spawns again in development, which is correct but breaks if the spawn is asynchronous. Tile two: the pagehide persisted flag decides between leaving a frozen worker alive for a back navigation and disposing it on a shorter budget. Tile three: a transferred ArrayBuffer is detached to length zero on the main thread, so a force-terminated task cannot be retried. Tile four: every hot module replacement update constructs another worker, so threads accumulate unless the module disposes the previous instance. StrictMode mounts the effect twice spawn dispose spawn again Correct in dev — it proves your cleanup actually runs. Danger: if the spawn is async, cleanup can fire before the worker exists. Keep a disposed flag and dispose in the resolution path when it is already set. pagehide: persisted decides persisted = true leave the worker alive persisted = false dispose, 100 ms budget A frozen worker resumes on Back, so tearing it down throws away a thread that was about to be reused. A transferred buffer cannot be retried transfer 8 MB, usable detached, length 0 before postMessage after — a retry throws Force-terminate that task and there is nothing left to resend. Keep a re-derivable source, or clone on retryable paths. HMR multiplies threads in dev each update re-evaluates the module that constructs it six saves, six live threads import.meta.hot.dispose(() => destroyWorker()) Hold the worker in a module-level singleton so there is one to dispose.
Each of these breaks a teardown that is otherwise correct — and three of the four only show up in development, which is exactly why they reach production.
Force-terminate side effects

After a hard terminate(), any ArrayBuffer the worker owned is destroyed with the isolate, and a buffer that was mid-transfer is lost on both sides — detached on the sender, never delivered to the receiver. Nothing throws; the next read simply sees a zero-length buffer. Issue the drain signal first and treat force-termination as the exception path, not the default.

Performance Note

The rule of thumb worth keeping: disposal costs the navigation nothing as long as you never await it. Dropping the reference and calling disposeWorker without await keeps the teardown entirely off the critical path — the next route renders while the drain runs on the worker’s own thread — and the timeout bounds the worst case for the worker, not for the user. Await it and you have converted a background cleanup into interaction latency, against an interaction-to-next-paint budget that web.dev puts at 200 ms for a “good” rating.

The timeout budget then only decides how much work you are willing to abandon. If the worker chunks its computation and yields every 5–20 ms — the pattern in Main Thread vs Worker Thread Lifecycle — the acknowledgement arrives about one slice after the signal, so 300 ms force-terminates essentially nothing and exists purely to catch a wedged thread. If instead the worker runs unchunked multi-second jobs, no timeout is correct: chunk the work rather than raising the number, because a budget long enough to wait out a four-second job is a budget that leaks a thread for four seconds on every route change.

Instrument the outcome rather than guessing. Record which branch disposeWorker returns and watch the 'timeout' rate: near zero means the protocol is working, a rising rate means either your slices grew or a task is blocking the worker’s event loop. Where several workers are being torn down at once, dispose them concurrently with Promise.allSettled rather than in sequence — the pool teardown case is developed in Worker Pool Management.

What awaiting disposal costs the navigation A time axis from zero to five hundred milliseconds after a route change begins, with a dashed threshold at two hundred milliseconds marking the good interaction-to-next-paint budget. In the awaited case, the drain occupies the critical path for two hundred and forty milliseconds and first paint is pushed past the threshold. In the fire-and-forget case the same drain runs on the worker’s own thread and first paint happens at about five milliseconds, well inside the budget, with the three hundred millisecond timeout bounding only the worker. 200 ms — the “good” INP budget awaited disposal await disposeWorker(worker) drain awaited — navigation blocked first paint pushed out by the full drain fire and forget void disposeWorker(worker) same drain, on the worker’s thread the 300 ms timeout bounds the worker, not the user first paint the next route has already rendered 0 100 200 300 400 500 milliseconds after the route change begins
The drain takes the same 240 ms either way. The only question is whose thread pays for it — and awaiting turns a background cleanup into interaction latency.

Frequently Asked Questions

Why does calling worker.terminate() on a route change still leave memory allocated?
Because terminate() only destroys the worker’s isolate — it does nothing about what the main thread still holds. The usual retainer is a pending task Promise: its resolver, its .then chain and every closure reachable from them (often the whole component tree) stay alive because nothing ever settles them. The second retainer is the listener you attached with addEventListener('message', …), which keeps its closure reachable through the Worker object until you remove it or drop the object. Settle every pending resolver, remove the listeners, and null the reference in the same code path that calls terminate().
How long should the drain timeout be before force-terminating a worker?
Long enough for one task slice, short enough to be invisible in a navigation. If the worker chunks its work and yields every 5–20 ms, an acknowledgement arrives within roughly one slice, so a 200–500 ms budget force-terminates almost nothing — start at 300 ms, record how often disposal returns 'timeout' instead of 'drained', and raise it only if that rate is material. The budget only matters if you await disposal on the navigation path, and you should not: fire it, drop the reference, and let the timeout clean up in the background.

See also