Framework Integration Patterns
A worker has a process-like lifetime and a component has a render-like one. Every integration bug in this area comes from confusing the two — a thread started in a mount hook, a result delivered to a view that no longer exists, twelve workers where the design called for one.
This topic sits under Web Workers Architecture & Communication and covers the framework-shaped half of worker lifecycle: who owns the thread, how a component borrows it safely, what development-mode remounting does to that arrangement, and why the arrival of a large result can cost more on the main thread than the computation you moved off it.
The Symptom: Twelve Threads and a Frozen Tab
A dashboard renders twelve chart cards. Each card component creates a worker in its mount effect to compute its series, and terminates it on unmount. In development on a fast laptop it works. In production, on a four-core laptop, the page takes 900 ms longer to become interactive and the fan spins up.
The arithmetic is unforgiving. Each worker costs 5–15 ms of main-thread time to construct (module fetch, parse, instantiate) and 2–5 MB of resident memory for its own JavaScript heap and stack. Twelve of them is 60–180 ms of blocking construction on the main thread, 24–60 MB of memory, and twelve OS threads competing for four cores — so each chart computes slower than it would have on a shared pool of four. Then a filter change unmounts and remounts the cards, and the whole cost is paid again.
The fix is not fewer workers per se; it is moving ownership. A worker belongs to the application, a request belongs to a component, and the two lifetimes are allowed to have nothing to do with each other.
Prerequisites
- A bundler that resolves
new URL('./worker.ts', import.meta.url)— Vite 3+, webpack 5, Rollup with a worker plugin, or Parcel 2. The per-tool differences are catalogued in Bundling Module Workers with Vite and webpack. - A decision about worker count. One shared worker, or a pool sized by
navigator.hardwareConcurrency, following Worker Pool Management. Components should never see the difference. - A cancellation story. Components unmount mid-flight constantly; a result arriving for a dead view must be discarded, not applied.
- Knowledge of whether your app renders on the server. If any part of the component tree is evaluated in Node, the worker constructor must be unreachable from module scope.
- A profiler run of the current page, so the improvement is measurable rather than assumed.
Step 1 — Give the Worker a Module-Scoped Owner
The whole pattern fits in one file per worker, and it is framework-agnostic: a lazily created singleton with an explicit dispose.
// analysis-service.ts — one worker for the whole application
import * as Comlink from 'comlink';
import type { AnalysisApi } from './analysis-worker';
let worker: Worker | null = null;
let api: Comlink.Remote<AnalysisApi> | null = null;
export function getAnalysis(): Comlink.Remote<AnalysisApi> {
// Browser-only: this function must never be called during server rendering.
if (!api) {
worker = new Worker(new URL('./analysis-worker.ts', import.meta.url), { type: 'module' });
api = Comlink.wrap<AnalysisApi>(worker);
}
return api;
}
export function disposeAnalysis(): void {
worker?.terminate();
worker = null;
api = null;
}
// Vite/webpack HMR: drop the old thread when this module is replaced.
if (import.meta.hot) import.meta.hot.dispose(disposeAnalysis);
Laziness matters as much as singleton-ness. A worker constructed at module evaluation time starts during the initial script execution — precisely the busiest moment of the page’s life — and it starts even for users who never touch the feature. Constructing on first use moves that 5–15 ms to a moment when the main thread is already idle.
If the first interaction must feel instant, warm the worker explicitly during idle time — requestIdleCallback(() => getAnalysis()) — rather than by constructing eagerly. That costs the same 5–15 ms but spends it where it competes with nothing. The scheduling options are compared in Task Scheduling & Prioritization.
Step 2 — Components Subscribe, They Do Not Own
A component’s job is to issue a request, apply the result if it is still relevant, and cancel on unmount. In React that is one effect; the same three lines exist in every framework under different names.
// useAnalysis.ts — React; see the dedicated guide for the full hook
import { useEffect, useState } from 'react';
import { getAnalysis } from './analysis-service';
export function useRange(series: Float64Array | null) {
const [range, setRange] = useState<{ min: number; max: number } | null>(null);
useEffect(() => {
if (!series) return;
let live = true; // the cancellation token
getAnalysis()
.rangeOf(series)
.then((r) => { if (live) setRange(r); }) // ignore late results
.catch((error) => { if (live) console.error(error); });
return () => { live = false; }; // unmount / dependency change
}, [series]);
return range;
}
The live flag is not defensive programming; it is the load-bearing part. Without it a fast typist produces four in-flight requests whose results arrive out of order, and the view settles on whichever finished last rather than whichever was asked last. With it, only the newest subscription can write.
Note also what the effect does not do: it never constructs, never terminates, and never touches the worker’s lifetime. That is what makes it safe under StrictMode’s deliberate double-mount — running the effect twice issues two requests and cancels one, which is harmless, whereas constructing twice would leak a thread.
Setting live = false discards the result; the worker still computes it and still occupies a thread. For expensive jobs, add a real cancellation path — an AbortSignal-shaped API on the worker, or a request id the pool can drop before dispatch, as in Cancelling Worker Tasks with AbortSignal.
Step 3 — Keep Big Results Out of the Reactive Graph
This is the step that surprises teams: the computation moves off the main thread, the page still janks, and the profile blames the framework rather than the worker. The cause is that reactivity systems do work proportional to the size of the value you hand them.
- Vue 3:
reactive(obj)andref(obj)wrap the object graph inProxyobjects lazily but per accessed key; assigning a 200,000-element array of row objects into reactive state creates proxies for every row that is read, and the deep-watch pass touches all of them. - MobX:
observable()converts arrays and plain objects deeply on assignment — O(n) allocations at arrival time. - React: no proxying, but a state update carrying a huge object re-renders every consumer and the reconciler diffs whatever it renders; the cost lands in your components rather than in the framework.
- Svelte 5 runes / Svelte 4 stores: assignment is cheap, but
$statedeep proxies in Svelte 5 behave like Vue’s, and an each block over 200,000 rows is a DOM problem regardless of the store.
The remedy is the same everywhere: keep the bulk out of reactivity and make only summaries reactive.
// vue: the typed array is raw; only the derived numbers are reactive
import { shallowRef, markRaw, computed } from 'vue';
const rows = shallowRef<Float64Array>(new Float64Array(0)); // no per-element proxy
const stats = shallowRef<{ min: number; max: number } | null>(null);
async function load(series: Float64Array) {
const result = await getAnalysis().summarise(series);
rows.value = markRaw(result.values); // never proxied, never deep-watched
stats.value = result.stats; // three numbers — cheap to observe
}
const label = computed(() => stats.value ? `${stats.value.min}–${stats.value.max}` : '…');
A useful mental separation: the worker returns data, the reactive store holds view state. A 32 MB Float64Array is data — it belongs in a plain holder, indexed by whatever renders it. The five numbers a header displays are view state.
Step 4 — Decide the Unit of Ownership: App, Route, or Pool
“Application-scoped” is a default, not a law. Three shapes cover essentially every product, and the choice follows from how long the worker’s warm state stays useful.
One worker for the application. Correct when the worker holds state worth keeping — a parsed dataset, a compiled WebAssembly module, a warmed cache — and when only one feature uses it at a time. Simplest to reason about, and the cheapest in memory.
A pool for the application. Correct when jobs are independent and CPU-bound, so several can run at once. The pool is created once, sized from navigator.hardwareConcurrency, and every feature dispatches into it. Components still see a single service object; they never learn how many threads exist. Sizing and queueing are covered in Worker Pool Management.
One worker per route or per document. Correct when the warm state is scoped to something the user leaves — an open spreadsheet, an editing session, a map view. Ownership then belongs to whatever object models that thing, and teardown is explicit:
// document-session.ts — a worker whose lifetime matches an open document
const sessions = new Map<string, { api: Comlink.Remote<DocApi>; worker: Worker }>();
export function openSession(docId: string) {
let entry = sessions.get(docId);
if (!entry) {
const worker = new Worker(new URL('./doc.worker.ts', import.meta.url), { type: 'module' });
entry = { worker, api: Comlink.wrap<DocApi>(worker) };
sessions.set(docId, entry);
}
return entry.api;
}
export function closeSession(docId: string) {
sessions.get(docId)?.worker.terminate();
sessions.delete(docId);
}
The anti-pattern this replaces is the one that looks most natural in a router: creating a worker in a route component’s mount hook and forgetting that the router keeps components alive for back-navigation, or destroys and recreates them on every parameter change. Give the map the ownership and the router only calls closeSession when a document is genuinely closed.
Whichever shape you pick, keep a single module as the sole place that calls new Worker. Every leak in this area has the same root cause: two files that can each start a thread.
Data-Transfer Strategy for Component Trees
| What the component needs | Move it as | Why |
|---|---|---|
| A handful of aggregates (min, max, count) | structured clone | tiny; goes straight into reactive state |
| A column of 100k+ numbers to chart | transferred Float64Array |
O(1) hand-off, and typed arrays skip per-row proxying |
| Rows for a virtualised table | transferred typed arrays + an index map | render only the visible window; never proxy the rest |
| Live progress during a long job | callback proxy or MessagePort |
avoids polling and keeps the view responsive |
| State two views read concurrently | SharedArrayBuffer |
no copy per consumer, but requires isolation headers |
A columnar shape is worth the small awkwardness in component code. An array of 200,000 objects costs 200,000 allocations on arrival plus whatever the reactivity system does to it; five Float64Arrays cost five. Where the data is genuinely shared and long-lived, SharedArrayBuffer & Atomics removes the arrival cost entirely.
Reaching for SharedArrayBuffer means serving Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp on every document response — which in a framework app also means every third-party embed (analytics, maps, video) must be CORP- or CORS-eligible, or it silently stops loading. Audit the embed list before committing to shared memory.
Verification & Measurement
Three checks separate a working integration from one that merely compiles.
Count the threads. Open the Performance panel, record an interaction, and count worker tracks. The number must match the design — one, or the pool size — and must not grow when you navigate between routes and back. A growing count is the component-owned mistake reappearing through a code path you forgot.
Confirm the arrival is cheap. Wrap the state assignment in marks and read the measure, so the arrival cost is a number rather than an impression:
performance.mark('result:start');
rows.value = markRaw(result.values);
performance.mark('result:end');
performance.measure('worker result → state', 'result:start', 'result:end');
Anything above ~5 ms means the value is being converted on assignment, and the fix is the raw-holder pattern rather than a faster worker.
Watch for zombie subscriptions. Navigate away from a view during a long job and confirm nothing throws and no state updates land. A setState on an unmounted React component no longer warns, so this failure is silent — check it deliberately.
Failure Modes & Error Handling
ReferenceError: Worker is not defined. The constructor ran during server rendering. Move it behind an effect or an onMount, never module scope — the full set of guards is in Server-Side Rendering and Worker Hydration Pitfalls.
Two workers in development, one in production. StrictMode or HMR. The singleton plus import.meta.hot.dispose above handles both; verify by counting tracks in a development profile.
Errors that never reach an error boundary. A worker error arrives as a MessageEvent on the worker object, not as a thrown exception in a component, so no error boundary or errorCaptured hook sees it. Route it deliberately: catch in the service, convert to a rejected promise or a store update, and let the framework’s own error path take over from there. The serialisation details are in Structured Error Serialization Across Threads.
A rejected promise with no .catch becomes a global error. Component code that fires a request and forgets it — void service.recompute() — turns any worker failure into an unhandledrejection on the window, which most error reporters attribute to the page rather than the feature. Attach a handler at the service boundary so every failure has an owner, even when the caller does not want the result.
A route change leaves a job running. The component is gone but the worker is still computing, so the next route competes with dead work. Cancel on unmount if the API supports it; otherwise size the pool so one abandoned job cannot starve the next.
Hydration mismatch after a worker fills a placeholder. If the server rendered “—” and the worker replaces it before hydration completes, frameworks that compare markup will warn or discard. Render the placeholder on both sides and update only after hydration is finished.
Angular and Solid: The Same Rules, Different Nouns
Angular’s dependency injection makes the ownership question explicit, which is an advantage: a service provided in root is exactly the module singleton described above, and ngOnDestroy on the service (not on components) is where termination belongs.
@Injectable({ providedIn: 'root' })
export class AnalysisService implements OnDestroy {
private worker?: Worker;
private api?: Comlink.Remote<AnalysisApi>;
constructor(@Inject(PLATFORM_ID) private platformId: object) {}
get analysis(): Comlink.Remote<AnalysisApi> {
if (!isPlatformBrowser(this.platformId)) throw new Error('worker requested on the server');
if (!this.api) {
this.worker = new Worker(new URL('./analysis.worker', import.meta.url), { type: 'module' });
this.api = Comlink.wrap<AnalysisApi>(this.worker);
}
return this.api;
}
ngOnDestroy(): void { this.worker?.terminate(); }
}
Components then inject the service and unsubscribe their observables on destroy — RxJS’s takeUntilDestroyed plays the role of the run-id guard, discarding results for a view that is gone.
Solid has no re-render model to defend against, so the singleton is even more natural: a module-scope createResource fed by the worker, with onCleanup invalidating. The one Solid-specific note is that stores built with createStore proxy deeply, so the same rule about large results applies — keep typed arrays in a plain signal, not in a store.
The pattern generalises because it is not really about frameworks. Threads are a process-level resource; component systems are a rendering concern. Any framework that keeps those two separate will integrate cleanly, and any code that ties them together will leak in exactly the ways described above.
Browser Compatibility
| Capability | Chrome | Firefox | Safari | Edge |
|---|---|---|---|---|
Module workers (type: 'module') |
80+ | 114+ | 15+ | 80+ |
new URL('./w.ts', import.meta.url) resolution |
build-time | build-time | build-time | build-time |
structuredClone() for cloning results in-page |
98+ | 94+ | 15.4+ | 98+ |
requestIdleCallback for warm-up |
47+ | 55+ | 17.4+ | 79+ |
SharedArrayBuffer (cross-origin isolated) |
92+ | 79+ | 15.2+ | 92+ |
Firefox’s module-worker support landed in 114, which is the practical floor for the new URL pattern without a bundler fallback. Safari gained requestIdleCallback only in 17.4, so a warm-up path should fall back to a setTimeout(…, 0) after first paint rather than assuming it exists.