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.

What each worker spelling emits into the production build Three build outcomes from the same worker source file. With new URL and import.meta.url, the bundler emits a separate hashed worker chunk that the page fetches on demand and the browser caches independently. With the question-mark worker suffix, the same separate chunk is emitted and the import provides a constructor that can be called repeatedly to build a pool. With the question-mark worker and inline suffix, no separate file is emitted and the worker source is base64-encoded into the page chunk, growing it by about one third and losing separate caching, but working from a cross-origin bundle. One source file, three build outputs new URL(...) portable across bundlers one instance per call site ?worker Vite only — gives a class convenient for pools ?worker&inline base64 into the page chunk works cross-origin analysis-a1b2.js separate chunk, cached on its own hash analysis-c3d4.js same chunk, plus a small constructor in the page no separate file page chunk grows ~4:3 no independent caching All three produce a module worker by default in Vite 5. The legacy build is where that changes. Pick inline only for cross-origin bundles or a worker small enough that the extra bytes do not matter.
The middle and left outputs are byte-identical in the worker chunk; the difference is only whether the page gets a URL or a constructor.

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 []; }
    },
  }],
});
Thread count across ten hot module replacements Two lines over ten saves during development. Without a dispose hook the live worker count climbs by one per save, reaching ten threads and roughly thirty megabytes of worker heap while the developer notices only that the machine grows warm. With a dispose hook that terminates the previous worker, the count stays at one for the whole session. Live worker threads while editing no dispose hook — one thread per save with dispose — steady at one 1 6 10 save 1 save 5 save 10 Ten stale threads also mean ten sets of listeners: an old worker can still answer a message and overwrite state with a result computed by code you deleted twenty minutes ago.
The second line of the caption is the real cost — stale threads do not merely waste memory, they produce results that contradict the code on screen.

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.


Entry-chunk weight for a 240 kB worker, by build strategy Three build strategies compared by how many bytes they add to the entry chunk. A separate worker chunk adds nothing to the entry and is fetched in parallel. An inlined worker adds about 320 kilobytes of base64 to the entry chunk. An inlined worker with an inline source map adds roughly 700 kilobytes, which is a development-only configuration that occasionally ships by accident. What each strategy adds to the entry chunk separate chunk 0 kB added — fetched in parallel ?worker&inline ≈320 kB of base64 in the entry inline + inline map ≈700 kB — ships by mistake 0 250 kB 500 kB 700 kB The entry chunk is downloaded by every visitor, including those who never use the feature.
Inline only when the origin constraint forces it — and never with a source map attached.

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.

Frequently Asked Questions

Should I use the ?worker import suffix or new URL with import.meta.url?
Use new URL('./w.ts', import.meta.url) when the code must also build under webpack, Rollup or Parcel — it is the portable spelling every modern bundler understands. Use Vite’s ?worker suffix when the project is Vite-only and you want a constructor you can call several times, or when you need ?worker&inline to embed the worker as a data URL. Both produce the same runtime object; only portability differs.
My worker works in dev and 404s in the production build. What changed?
Almost always a worker URL built from a variable or a template string. Bundlers resolve worker URLs statically, so new URL(path, import.meta.url) with a computed path emits nothing and the browser requests a file that was never written. Keep the specifier a literal, and if you genuinely need several workers, use a static map from key to literal-URL factory functions.

See also