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.

Worker owned by a component versus owned by the application Two timelines. In the upper one, each of three component mounts constructs its own worker, paying five to fifteen milliseconds of construction each and holding two to five megabytes each, and every unmount terminates the thread and throws away its warm state, so a remount pays the full cost again. In the lower one, a single application-scoped pool is constructed once during idle time; each component mount only issues a request and registers a cancellation, costing nothing at mount and nothing at unmount, and the warm worker state survives every remount. Who owns the thread decides what a remount costs component-owned new Worker new Worker new Worker mount · 5–15 ms each · 2–5 MB each · terminate on unmount discards warm state a filter change pays all of it again application-owned pool starts once, idle request request request mount costs one message · unmount cancels the request · warm caches, parsed modules and compiled WebAssembly survive every remount Component lifetimes are measured in renders; thread lifetimes should be measured in sessions.
Nothing about the lower timeline is framework-specific — it is the same singleton discipline that database connections and WebSocket clients get, applied to a thread.

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.

Warm it deliberately, if at all

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.

Cancellation flags do not stop the work

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) and ref(obj) wrap the object graph in Proxy objects 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 $state deep 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.

Where main-thread time goes after a worker result arrives Two stacked bars for the same 200,000-row result. In the naive path the worker computation takes 180 milliseconds off the main thread, then arrival costs 12 milliseconds of structured clone, 140 milliseconds of making the result deeply reactive, and 60 milliseconds of rendering, producing a 212 millisecond main-thread block after the work was supposedly offloaded. In the raw-holder path the same result costs 12 milliseconds of clone, under 1 millisecond to store as a raw value, and 14 milliseconds to render a virtualised view, for a 27 millisecond main-thread cost. Main-thread cost on arrival of a 200,000-row result deep reactive proxying every row — 140 ms render 60 ms 212 ms blocked — the jank came back raw holder 27 ms — under two frames 0 70 140 210 ms structured clone on arrival making it reactive render / reconcile The worker's 180 ms of computation is off-screen in both bars — it is not the variable being measured.
Offloading is only half the job. If arrival costs 200 ms of proxy allocation, the user experiences the same stutter with a different cause in the profile.

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.

Isolation headers apply to the whole document

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.


Main-thread cost of a filter change on a twelve-card dashboard Three ownership models measured on the same interaction. With a worker per component, twelve threads are constructed and terminated on every filter change, costing about 150 milliseconds of main-thread time. With one shared worker the interaction costs about 12 milliseconds, all of it messaging. With a pool of four the cost is the same 12 milliseconds, and the work finishes sooner because four cores are used. Cost of one filter change, by who owns the thread worker per card ≈150 ms of construction and teardown one shared worker ≈12 ms — messaging only pool of four ≈12 ms, and four cores working 0 50 100 150 ms The bars measure the main thread only. The computation itself is unchanged in all three.
Ownership is not a style preference: it is the difference between an interaction that blocks for a tenth of a second and one that does not.

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.

Frequently Asked Questions

Should a component own its worker, or should the app?
The app, in almost every case. A worker costs 5–15 ms and 2–5 MB to start, and component lifetimes are short and unpredictable — a list that mounts twelve rows would start twelve threads. Own the worker (or a pool) in a module-level singleton, and let components own only their subscription to it: a request in, a cancellation on unmount.
Why does my worker start twice in development but once in production?
React’s StrictMode deliberately mounts, unmounts and remounts every component once in development to surface effects that are not idempotent, and most bundlers’ hot module replacement re-evaluates a module without discarding the previous instance. Both duplicate a worker created inside an effect or at module scope. Create workers lazily through a singleton accessor and dispose them on import.meta.hot.dispose, and both behaviours become harmless.
How do I avoid \"Worker is not defined\" during server-side rendering?
Never construct a worker at module scope in code the server evaluates. Create it inside an effect, an onMount, or behind a typeof window !== 'undefined' guard so the constructor only runs in the browser. Frameworks that render on the server — Next.js, Nuxt, SvelteKit, Angular Universal — all evaluate component modules in Node, where Worker does not exist in the global scope.
Does putting worker results into reactive state cost anything?
It can cost more than the computation. Vue’s reactive() and MobX both wrap plain objects in proxies on assignment, which is O(n) in the number of keys, so a 200,000-row result is 200,000 proxy allocations on the main thread — the jank you moved to the worker reappears on arrival. Store large results with shallowRef, markRaw, or a plain non-reactive holder, and expose only derived summaries reactively.

See also