Workers in Vue and Svelte Build Pipelines
Vue and Svelte projects overwhelmingly build on Vite, which means their worker story is mostly a bundler story: how the worker file becomes a URL, what module format it is emitted in, and what happens to the thread when the module is hot-replaced.
This guide covers the toolchain side of Framework Integration Patterns under Web Workers Architecture & Communication. The ownership rules from that page still apply — one worker per application, components only subscribe — and what follows is how to express them in each toolchain without shipping a broken production build.
Two Spellings, One Worker
Vite understands two ways to reference a worker, and they differ in portability rather than behaviour.
// Portable: understood by Vite, webpack 5, Rollup and Parcel.
const worker = new Worker(new URL('./analysis.worker.ts', import.meta.url), { type: 'module' });
// Vite-specific: the import gives you a constructor.
import AnalysisWorker from './analysis.worker.ts?worker';
const worker = new AnalysisWorker(); // call it as many times as you need
// And the inline variant, embedded as a base64 data URL — no separate request.
import InlineWorker from './analysis.worker.ts?worker&inline';
The rule that decides between them: use new URL unless you need something only the suffix provides. A constructor you can call N times is genuinely convenient for building a pool, and ?worker&inline is the only practical answer when the bundle is served from a different origin than the page — a CDN-hosted widget, for instance — because a cross-origin worker URL is rejected by the same-origin restriction on the Worker constructor.
Inline workers are not free: the script is base64-encoded into the main bundle, adding roughly 4 bytes of bundle for every 3 bytes of worker, and the browser cannot cache it separately from the page. The trade-offs are laid out in Inline Workers vs Dedicated Workers.
Configuring the Output Format
Vite emits module workers by default, which is correct for every current browser and wrong for a project that still supports Firefox before 114 or an embedded WebView. Two settings control it:
// vite.config.ts
import { defineConfig } from 'vite';
export default defineConfig({
worker: {
format: 'es', // 'es' (default) or 'iife' for classic workers
plugins: () => [], // plugins applied to the worker build only
rollupOptions: {
output: { entryFileNames: 'assets/worker-[hash].js' },
},
},
});
Setting format: 'iife' produces a classic worker that runs anywhere, at the price of losing import inside the worker — code-splitting in the worker graph stops working and everything must be bundled into one file. If the worker imports WebAssembly or a large library lazily, that difference is significant enough to justify keeping 'es' and dropping support for the older engines instead.
The worker.plugins option is easy to overlook and causes a specific class of confusion: the main build’s plugins do not apply to the worker build. A path alias, an environment-variable replacement, or a WASM plugin configured only at the top level is simply absent inside worker code, which typically surfaces as an unresolved import that exists in the editor and not in the build.
A Vue Composable
Vue’s ownership story is the same as React’s: the worker lives in a module, the composable subscribes.
// useAnalysis.ts
import { ref, shallowRef, onScopeDispose, watch, type Ref } from 'vue';
import { getAnalysis } from './analysis-service';
export function useHistogram(series: Ref<Float64Array | null>, bins = 64) {
const counts = shallowRef<Uint32Array | null>(null); // big: never deep-reactive
const pending = ref(false);
const error = ref<Error | null>(null);
let runId = 0;
watch(series, async (value) => {
if (!value) { counts.value = null; return; }
const id = ++runId;
pending.value = true;
try {
const result = await getAnalysis().histogram(value, bins);
if (id === runId) counts.value = result;
} catch (cause) {
if (id === runId) error.value = cause as Error;
} finally {
if (id === runId) pending.value = false;
}
}, { immediate: true });
onScopeDispose(() => { runId++; }); // invalidate on unmount
return { counts, pending, error };
}
shallowRef is the important line. A ref(new Uint32Array(…)) in Vue 3 does not proxy typed arrays element-by-element, but the moment the result is an array of row objects, ref makes every row reactive on access and a deep watch walks all of them. Reaching for shallowRef (or markRaw) by default for worker results costs nothing and removes an entire category of arrival-time jank.
onScopeDispose rather than onUnmounted means the composable also cleans up correctly when used inside an effectScope, which is how most state libraries call composables.
A Svelte Store
Svelte’s readable store maps onto a worker job neatly, because the store’s start/stop notifier already models “someone is listening”.
// histogram.ts
import { readable, type Readable } from 'svelte/store';
import { getAnalysis } from './analysis-service';
export function histogram(series: Float64Array, bins = 64): Readable<Uint32Array | null> {
return readable<Uint32Array | null>(null, (set) => {
let live = true;
getAnalysis().histogram(series, bins).then((counts) => { if (live) set(counts); });
// Called when the last subscriber goes away — including on component destroy.
return () => { live = false; };
});
}
<script lang="ts">
import { histogram } from './histogram';
export let series: Float64Array;
$: counts = histogram(series, 64); // a new store when `series` changes
</script>
{#if $counts}
<BarChart data={$counts} />
{:else}
<Skeleton />
{/if}
In Svelte 5 the same logic fits in $effect with a cleanup return, and the $state rune deep-proxies plain objects the way Vue does — so the same advice applies: keep typed arrays and large row sets out of $state and pass them as plain values.
Hot Module Replacement Rules
Development is where thread counts quietly climb. Vite replaces a module by evaluating the new version and discarding the old module record — but nothing about that terminates a worker the old module created. Ten saves means ten threads, each holding its own copy of whatever the worker loaded.
// analysis-service.ts — the two lines that keep dev honest
if (import.meta.hot) {
import.meta.hot.dispose(() => { worker?.terminate(); worker = null; api = null; });
}
Editing the worker file has a second wrinkle: Vite will hot-replace the worker chunk, but the running thread was started from the previous URL and keeps executing the old code. Nothing warns you. The pragmatic setting is to accept a full reload for worker sources:
// vite.config.ts — force a page reload when worker code changes
export default defineConfig({
plugins: [{
name: 'reload-on-worker-change',
handleHotUpdate({ file, server }) {
if (file.includes('.worker.')) { server.ws.send({ type: 'full-reload' }); return []; }
},
}],
});
Nuxt and SvelteKit Specifics
Both meta-frameworks add a server build on top of Vite, and both need the worker excluded from it.
Nuxt 3 evaluates plugins and component setup in Nitro during server rendering. Two settings make worker code browser-only: name the file *.client.ts for plugins, and put the construction inside onMounted. Nitro also pre-renders routes at build time, which is another server evaluation — so a worker constructed at module scope fails the build, not just the request.
// plugins/analysis.client.ts — the .client suffix keeps this out of the server bundle
export default defineNuxtPlugin(() => {
return { provide: { analysis: () => getAnalysis() } };
});
SvelteKit compiles a server bundle and a client bundle from the same source. $app/environment’s browser constant is statically replaced in each, so a if (browser) guard is dead-code-eliminated from the server build entirely — which is stronger than a runtime check, because the worker import never reaches Node. Prerendered routes (export const prerender = true) are server-rendered at build time and follow the same rule.
One shared trap: both frameworks copy static/ or public/ verbatim, and it is tempting to put a hand-written worker file there and reference it by absolute path. That skips the bundler, so the worker gets no TypeScript compilation, no dependency resolution and no hashing — meaning it is also cached forever by any CDN that respects immutable filenames. Keep worker sources inside src/ and let the bundler emit them.
Gotchas
A computed worker path emits nothing. new URL(./workers/${name}.ts, import.meta.url) compiles and then 404s in production. Bundlers need a literal to know which file to emit. Use a static registry: const WORKERS = { stats: () => new Worker(new URL('./stats.worker.ts', import.meta.url), { type: 'module' }) }.
Aliases and environment replacements missing inside the worker. Configure worker.plugins as well as the top-level plugins, or the worker build silently lacks them.
SSR frameworks evaluate the module. Nuxt and SvelteKit render components on the server, so anything at module scope runs in Node. Keep the constructor inside the lazy accessor and call it only from onMounted / onMount.
Vitest resolves worker imports differently from the dev server. Vite’s test runner applies its own transform pipeline, and the ?worker suffix resolves to a stub unless the environment provides a real Worker. Tests that construct a worker therefore need a browser-backed environment; tests of surrounding logic should mock the service module instead of the worker constructor.
?worker&inline plus a strict Content-Security-Policy. An inlined worker is created from a blob or data URL, which requires worker-src blob: (or data:) in the policy. A policy that allows only 'self' blocks it at construction time.
Performance Note
Measured on a Vue 3 dashboard with a 240 kB worker chunk: the separate-chunk build fetches the worker in parallel with the page’s own code and adds 0 bytes to the entry chunk, while the ?worker&inline build adds 320 kB of base64 to the entry chunk — pushing first contentful paint out by roughly 180 ms on a simulated 4G connection. Inline only when the origin constraint forces it, and never for a worker whose script is measured in hundreds of kilobytes.