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.
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' });
});
}
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.
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.
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:
- Open DevTools → Memory, and take a heap snapshot while sitting on the route that owns the worker.
- Filter the class list for
DedicatedWorkerGlobalScopeto see the live and detached scopes. - Navigate away, force a garbage collection, and take a second snapshot.
- Switch to the Comparison view and look for
DedicatedWorkerGlobalScopedeltas that never go negative. - Inspect retained
MessagePortobjects — 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 |
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.
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.