Bundling Module Workers with Vite and webpack

A build tool can only bundle a worker it can see at compile time — so the exact shape of your new Worker() call decides whether you get a type-checked, tree-shaken, source-mapped worker chunk or a 404 in production.

This page drills into the build-tool half of Inline Workers vs Dedicated Workers, which sits under the broader Web Workers Architecture & Communication reference. The rules below apply to Vite 3 and newer and to webpack 5 and newer; webpack 4 and Create React App predate native worker support and still need worker-loader.

Minimal Reproducible Example

The following two files work in both Vite and webpack 5 with no plugin and no bundler configuration at all.

// main.ts
const worker = new Worker(
  new URL('./sort.worker.ts', import.meta.url),
  { type: 'module' }
);

worker.onmessage = (e: MessageEvent<number[]>) => {
  console.log('sorted:', e.data);
};

worker.postMessage([5, 1, 9, 3]);
// sort.worker.ts
self.onmessage = (e: MessageEvent<number[]>) => {
  const sorted = [...e.data].sort((a, b) => a - b);
  self.postMessage(sorted);
};

Two files, zero config. Both tools recognise new Worker(new URL(<literal>, import.meta.url)) as a worker dependency: they add sort.worker.ts to the module graph as a second entry point, compile it with the same loaders and plugins as your application code, emit it as its own hashed chunk, and rewrite the new URL() expression to point at that chunk’s final filename.

From two source files to two hashed chunks main.ts and sort.worker.ts enter Vite 3 or webpack 5 with no configuration. The bundler pattern-matches the new URL literal, adds the worker as a second entry point, compiles it with the same loaders and plugins, and emits it as its own hashed chunk. In the dist directory the main chunk's rewritten new URL expression points at that emitted worker filename. src/ main.ts new Worker(new URL(…)) sort.worker.ts self.onmessage = … zero config, zero plugins Vite 3+ · webpack 5+ detect new URL(literal, import.meta.url) add the worker as a 2nd entry point same loaders, plugins, TS, tree-shake emit hashed chunk, rewrite the URL one module graph per entry point dist/assets/ main-C1x9kQ.js your application entry rewritten new URL() sort.worker-DkP2fa.js its own hashed worker chunk The dev server serves the same graph from memory — only the production build proves the second chunk exists.
Both tools treat new Worker(new URL(<literal>, import.meta.url)) as a worker dependency: a second entry point, compiled by the same pipeline, emitted as its own chunk, with the constructor's URL rewritten to the final hashed filename.

Step-by-Step Walkthrough

Every character of the constructor call matters. Here is the same expression annotated line by line.

const worker = new Worker(
  new URL('./sort.worker.ts', import.meta.url),
  // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  // 1. new URL(literal, import.meta.url) is the idiom both bundlers
  //    pattern-match on. The first argument MUST be a string literal —
  //    no variables, no template literals, no string concatenation.
  //    import.meta.url resolves against the *current module's* URL,
  //    so './sort.worker.ts' is relative to this file, not to the
  //    project root or to the output directory.

  { type: 'module' }
  // ^^^^^^^^^^^^^^^^
  // 2. type: 'module' loads the worker as an ES module, which enables
  //    static import/export inside it. Without the option the browser
  //    runs the file as a classic script and a bare import statement
  //    throws a SyntaxError the moment the worker boots.
);

Inside the worker script the annotations are about the message contract rather than the build:

self.onmessage = (e: MessageEvent<number[]>) => {
  // 3. The file is type-checked against WorkerGlobalScope, not Window.
  //    That only works if "webworker" is in your tsconfig lib array —
  //    see the TypeScript section below for the split-config recipe.

  const sorted = [...e.data].sort((a, b) => a - b);
  // 4. e.data is already a private copy produced by the structured
  //    clone algorithm, so sorting it in place would be safe — but the
  //    spread makes the intent explicit and survives a later refactor
  //    to a transferred, shared or pooled buffer.

  self.postMessage(sorted);
  // 5. A plain Array round-trips through structured clone. Past roughly
  //    100k elements, switch to a Float64Array posted in a transfer
  //    list so the payload moves by pointer instead of being copied.
};

Two of those annotations point at neighbouring topics: the copy semantics of step 4 are covered in the Step-by-Step Guide to the Structured Clone Algorithm, and the zero-copy alternative in step 5 is Transferable Objects & Zero-Copy.

Anatomy of the worker constructor expression Three parts of new Worker(new URL('./sort.worker.ts', import.meta.url), { type: 'module' }) explained in turn: the path must be a string literal so the bundler can pattern-match it; import.meta.url makes the path resolve against the current module's URL rather than the project root or the output directory; and type module gives the worker ES module scope so static import works. A fourth, crossed-out row shows a path built by string concatenation, which the bundler never detects — it works in the dev server and 404s in production. new Worker(new URL('./sort.worker.ts', import.meta.url), { type: 'module' }) 1 new URL('./sort.worker.ts', import.meta.url) The path must be a string literal Both bundlers pattern-match this exact shape while parsing; a variable or a template literal drops it from the graph. 2 new URL('./sort.worker.ts', import.meta.url) import.meta.url is the resolution base The path resolves against this module's own URL — not the project root, and not the output directory. 3 new Worker(url, { type: 'module' }) type: 'module' switches on ES module scope Without it the browser runs the file as a classic script and the first bare import throws a SyntaxError at boot. new URL('./' + n + '.worker.ts', import.meta.url) A computed path is never detected The dev server still finds the file on disk, so this fails only in the production build: a 404, with no chunk emitted.
Each part of the expression carries a different requirement — literal path for detection, module-relative base for resolution, module type for ES semantics — and losing any one of them fails in a different place.

Vite-Specific Worker Imports

Vite supports the standard idiom and adds two import-suffix shortcuts that give you a typed constructor instead of a URL.

// Approach A — worker as a constructor (Vite only)
import SortWorker from './sort.worker.ts?worker';
const worker = new SortWorker(); // same as new Worker(url, { type: 'module' })

// Approach B — inline worker, embedded in the main bundle
import InlineSortWorker from './sort.worker.ts?worker&inline';
const inlineWorker = new InlineSortWorker();
// The compiled worker is base64-encoded into the importing chunk and
// revived through a Blob URL at construction time — no extra request.

?worker emits the worker as its own content-hashed chunk, which is what you want for anything cached across navigations. ?worker&inline skips the request entirely by encoding the compiled script into the parent bundle. That is genuinely useful for strict Content-Security-Policy deployments that allow blob: but not additional script origins, for Electron renderers loading from file://, and for libraries that must ship as a single file — but it costs roughly a third more bytes before compression and it forces a self-contained single-chunk build, so an inline worker cannot code-split or dynamically import.

Inline workers built by Vite are still bundled: they get TypeScript compilation, tree-shaking and source maps. A hand-rolled Blob worker — building a script from a template string and calling URL.createObjectURL() — gets none of that, because the bundler sees only an opaque string. If you want an inline worker, let the bundler make it:

// BAD — a raw Blob worker is invisible to the build
const src = `import { heavyFn } from './lib.ts'; self.onmessage = () => {};`;
const blob = new Blob([src], { type: 'application/javascript' });
const worker = new Worker(URL.createObjectURL(blob));
// heavyFn was never bundled; this throws at runtime in production.

Worker output is configured globally rather than per import:

// vite.config.ts
import { defineConfig } from 'vite';

export default defineConfig({
  worker: {
    format: 'es',       // 'iife' (default) or 'es' — see gotcha 2 below
    plugins: () => [],  // Vite 5+ requires a FUNCTION here, not an array
  },
});
Type declarations for ?worker imports

The ?worker and ?worker&inline suffixes are Vite transform flags, and TypeScript knows nothing about them — you will get Cannot find module until the client types are loaded. Add /// <reference types="vite/client" /> to a declaration file in the project, or put "vite/client" in compilerOptions.types in tsconfig.json.

Vite's three worker import forms and what they emit The portable new URL form and the Vite-only question-mark-worker suffix both emit two files — the main chunk and a separately cached worker chunk — costing two requests. The question-mark-worker-ampersand-inline suffix emits one file: the compiled worker is base64-encoded into the main chunk and revived through a Blob URL, costing one request but forcing a single self-contained chunk. portable idiom new URL('./w.ts', import.meta.url) lands in dist/ main-[hash].js your app code w-[hash].js cached across navigations 2 requests any bundler Vite shortcut import W from './w.ts?worker' lands in dist/ main-[hash].js holds a typed constructor w-[hash].js same chunk as new URL() 2 requests Vite only Vite shortcut, inlined import W from './w.ts?worker&inline' lands in dist/ main-[hash].js base64 worker payload revived through a Blob URL 1 request Vite only An inlined worker adds roughly a third more bytes to the parent chunk and can never code-split or dynamically import.
All three forms are bundled — TypeScript, tree-shaking and source maps included. The only real differences are how many files land in dist/, whether the worker bytes are cached on their own, and whether the import survives a move off Vite.

webpack 5 Native Worker Support

webpack 5 recognises the same new URL() expression natively; worker-loader is obsolete and mixing the two produces duplicate worker bundles, so remove it.

// webpack.config.js — nothing worker-specific is required
module.exports = {
  mode: 'production',
  output: {
    filename: '[name].[contenthash].js',
    // Worker chunks inherit this naming automatically. Do NOT declare
    // the worker as a second entry point — that would emit it twice.
  },
};

webpack code-splits the worker, applies the same loader rules to it, and injects __webpack_public_path__ so the worker URL resolves under whatever deployment prefix you serve from. Two options are worth knowing about:

// webpack.config.js — the two knobs that actually matter for workers
module.exports = {
  output: {
    // Where the worker looks for its own chunks at runtime. Set this
    // when your bundles live on a CDN — see gotcha 4.
    workerPublicPath: '/assets/',
  },
  module: {
    parser: {
      javascript: {
        // Teach webpack to detect a custom worker wrapper class,
        // e.g. `new MyWorker(new URL('./w.ts', import.meta.url))`.
        worker: ['MyWorker from ./src/my-worker.ts', '...'],
      },
    },
  },
};
webpack and module-type workers

By default webpack compiles the worker chunk to a classic script, so the emitted code runs even when { type: 'module' } is ignored. To emit a real ES module worker you must opt in with experiments.outputModule: true and output.module: true; that requires module workers in the browser — Chrome and Edge 80+, Safari 15+, Firefox 114+. Keep the default unless the worker itself needs import() at runtime.

The webpack 5 worker graph and its two public paths The parser detects the new URL expression inside src/main.ts and promotes sort.worker.ts to its own entry graph, compiled with the same loaders and plugins. Any dynamic import inside the worker splits into further numbered chunks that the worker fetches at runtime. __webpack_public_path__ is injected into the main runtime and rewrites the worker URL under your deployment prefix, while output.workerPublicPath sets the base the worker itself uses to fetch its own chunks — the setting that matters when bundles are served from a CDN. __webpack_public_path__ injected into the main runtime; rewrites the worker URL under your deployment prefix src/main.ts the declared entry point parser detects new URL() sort.worker.[hash].js its own entry graph same loaders, same plugins default output: classic script experiments.outputModule → ESM import() 821.[hash].js split out of the worker 934.[hash].js fetched at runtime output.workerPublicPath the base the worker itself resolves its own chunks from — set it when you ship to a CDN Do not add the worker as a second entry in the config — that emits it twice. worker-loader is obsolete on webpack 5.
webpack promotes the detected worker to its own graph and keeps code splitting inside it. The two public-path settings answer different questions: one fixes the URL the page uses to reach the worker, the other fixes the URLs the worker uses to reach its own chunks.

Vite vs webpack: Bundling Behaviour Compared

Behaviour Vite new URL() Vite ?worker Vite ?worker&inline webpack 5 new URL()
Plugin required No No No No (webpack ≥ 5)
Emits separate chunk Yes Yes No — embedded Yes
Extra network request Yes Yes No Yes
Default output format worker.format worker.format IIFE (forced) Classic script
ES module output format: 'es' format: 'es' Not supported experiments.outputModule
Code splitting inside worker With format: 'es' With format: 'es' No Yes
TypeScript out of the box Yes Needs vite/client types Needs vite/client types Yes
Source maps inside worker Yes Yes Yes (inlined) Yes (via devtool)
Portable across bundlers Yes No — Vite only No — Vite only Yes

The last row is the one to weigh first: new URL() is the only form that survives a migration between build tools, so prefer it in shared libraries and reserve the suffix imports for application code that is already committed to Vite.

Choosing a worker import form Three questions asked left to right. If the code must survive a move to another bundler, use new URL — the only portable form. If not, and the worker must load with no extra network request, use the inline suffix, accepting one self-contained chunk with no code splitting. If not, and you want a typed constructor and many instances, use the ?worker suffix. Otherwise stay on new URL. Any answer whose worker uses dynamic import also needs an ES module worker output format. new URL() the only portable form yes Must this build survive a move to another bundler? no ?worker&inline one chunk, no code splitting yes Must the worker load with no extra network request? no ?worker Vite only, own hashed chunk yes Do you want a typed worker constructor and many instances? no new URL() keep the portable default Cutting across every answer: if the worker itself uses dynamic import() or top-level await, it needs an ES module output: worker.format 'es' in Vite, or experiments.outputModule in webpack. Portability is the first question because it is the only one a later migration cannot undo cheaply.
Ask portability first: the suffix imports are Vite transform flags, so a shared library that uses them has to be rewritten before it can be built by anything else. Everything below that line is a request-count-versus-caching trade.

TypeScript Configuration for Module Workers

Worker files need the webworker lib so self, postMessage and WorkerGlobalScope type-check, and moduleResolution: "bundler" so the extension-ful relative paths a bundler accepts do not error:

{
  "compilerOptions": {
    "lib": ["es2022", "dom", "webworker"],
    "module": "esnext",
    "moduleResolution": "bundler",
    "target": "es2022"
  }
}

Loading both dom and webworker into one program is a stopgap: the two libraries declare conflicting globals (self, postMessage, addEventListener, location), so TypeScript silently resolves each name to whichever declaration wins, and you lose the errors that would have caught DOM access from inside a worker. The clean fix is a second config scoped to worker files only:

// tsconfig.worker.json
{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "lib": ["es2022", "webworker"]
  },
  "include": ["src/**/*.worker.ts"]
}

Then exclude src/**/*.worker.ts from the base config. With vite build, type-checking is a separate tsc --noEmit -p tsconfig.worker.json step because Vite strips types without checking them. With webpack, point the ts-loader rule for *.worker.ts at the alternate config via its configFile option.

Two programs, one overlap The base tsconfig.json keeps the dom lib, includes the application sources and excludes files matching star dot worker dot ts. A second tsconfig.worker.json extends it, swaps in the webworker lib and includes only the worker files. The overlap between the two libraries is where self, postMessage, addEventListener and location are declared twice: load both into one program and TypeScript resolves each name to whichever declaration wins, silently. application program worker program tsconfig.json "lib": ["es2022", "dom"] "include": ["src/**/*"] "exclude": ["src/**/*.worker.ts"] Window, document, DOM types tsc --noEmit -p tsconfig.json worker files are not in this program at all tsconfig.worker.json "extends": "./tsconfig.json" "lib": ["es2022", "webworker"] "include": ["src/**/*.worker.ts"] self, WorkerGlobalScope, no DOM tsc --noEmit -p tsconfig.worker.json touching document here is now a compile error declared by BOTH libs self postMessage addEventListener location in one program TypeScript picks a winner — and never tells you Loading dom and webworker together compiles, but it hides exactly the errors that would have caught DOM access inside a worker.
Splitting the program is what restores the type errors: each file is checked against the one global scope it actually runs in, so document inside a worker fails at build time instead of at runtime.

Gotchas & Edge Cases

1. A computed path silently disables detection

// BAD — neither bundler can resolve this; the file is never emitted
const name = condition ? './a.worker.ts' : './b.worker.ts';
const worker = new Worker(new URL(name, import.meta.url), { type: 'module' });
// Dev server: works (the source file is still on disk).
// Production: 404, because nothing wrote a.worker.[hash].js.

// GOOD — one literal per branch keeps both workers in the graph
const worker = condition
  ? new Worker(new URL('./a.worker.ts', import.meta.url), { type: 'module' })
  : new Worker(new URL('./b.worker.ts', import.meta.url), { type: 'module' });

This is the failure mode that hurts most, because it never reproduces locally — the dev server resolves the path from the filesystem, so the bug only exists in the built output.

2. Dev runs ES modules; the production build may not

Vite’s dev server always instantiates workers as native ES modules, while vite build defaults worker.format to iife. The consequences run in both directions: code that relies on import.meta.url or top-level await inside the worker works in dev and breaks in the build, and a worker that boots fine in the build can fail in dev on a browser without module worker support. Pin the behaviour by setting worker: { format: 'es' } when the worker needs ES semantics, and smoke-test the production build in every target engine rather than trusting the dev server.

A related dev-only trap: module workers are subject to strict MIME checking, so a custom Express or proxy layer in front of the dev server that serves .ts files as text/plain will make the worker fail to load with a MIME error while the same code builds and runs perfectly.

3. import.meta.url requires an ES module context

import.meta only exists in modules. If your tsconfig.json emits CommonJS ("module": "commonjs"), or webpack classifies the file as javascript/dynamic or CommonJS rather than javascript/esm, the expression fails to compile — webpack reports Cannot use ‘import.meta’ outside a module. Use "module": "esnext" (or "es2022"), keep worker call-sites in files webpack treats as ESM, and remember that on webpack 4 and Create React App this idiom does not exist at all: those builds still need worker-loader or an ejected configuration.

4. A CDN-hosted worker chunk throws SecurityError

The Worker constructor requires a same-origin script URL. If your bundles are served from cdn.example.com while the document is on app.example.com, the rewritten worker URL is cross-origin and construction throws a SecurityError — even though the CDN serves permissive CORS headers. Two fixes: set output.workerPublicPath in webpack (or Vite’s base) to a same-origin path and serve the worker chunk from your own domain, or fetch the cross-origin script yourself and boot it from a same-origin Blob URL:

// Same-origin shim for a cross-origin worker chunk
const res = await fetch('https://cdn.example.com/assets/sort.worker.js');
const src = await res.text();
const url = URL.createObjectURL(new Blob([src], { type: 'text/javascript' }));
const worker = new Worker(url, { type: 'module' });
// Revoke only after the worker has signalled it is running; revoking
// while the script is still being fetched can abort the load.
worker.addEventListener('message', () => URL.revokeObjectURL(url), { once: true });

The shim costs an extra round-trip and only works when the worker is a single self-contained chunk — a Blob URL has no useful base for resolving further relative imports, which is the same constraint that stops Vite’s inline workers from code-splitting. It also applies to a worker that instantiates WebAssembly, where the .wasm asset URL must resolve from the worker’s own base; see Instantiating WebAssembly Modules Inside Workers for that resolution order.

Four failure modes and their evidence A computed worker path is never added to the graph, so production returns 404 for a chunk that was never emitted; fix it with one literal new URL per branch. The Vite dev server runs workers as native ES modules while the build defaults to an IIFE, so top-level await works in dev and breaks in dist; fix it by pinning worker.format to es. import.meta does not exist outside an ES module, so a CommonJS emit fails to compile; fix it with module esnext and ESM call-sites. And the Worker constructor rejects a cross-origin script URL with a SecurityError even under permissive CORS; fix it with workerPublicPath on your own origin or a same-origin Blob shim. 1 A computed path The path comes from a variable, so neither bundler adds the worker to the module graph and no chunk is emitted. GET /assets/a.worker.js → 404 (production only) Fix: one literal new URL() per branch. 2 Dev is ESM, the build is not vite dev always runs workers as native ES modules, while vite build defaults worker.format to iife. top-level await → fine in dev, broken in dist/ Fix: pin worker.format, then test the real build. 3 import.meta outside a module A CommonJS emit, or a file webpack classifies as CJS, has no import.meta at all — it fails at compile time. Cannot use 'import.meta' outside a module Fix: module esnext, and keep call-sites in ESM files. 4 The chunk lives on a CDN The Worker constructor requires a same-origin script URL; permissive CORS headers do not change that. new Worker('https://cdn…/w.js') → SecurityError Fix: workerPublicPath, or a same-origin Blob shim.
Only the third of these fails at build time. The other three compile cleanly and surface as a missing file, a runtime syntax error or a thrown SecurityError — which is why the production build has to be exercised, not just produced.

Verifying the Bundle Output

Do not trust the dev server. Build for production and check that a hashed worker file actually exists:

# Vite
$ vite build
dist/assets/main-C1x9kQ.js          # main bundle
dist/assets/sort.worker-DkP2fa.js   # worker chunk — present ✓

# webpack
$ webpack --mode production
dist/main.4f2a1c.js
dist/821.9b3e77.js                  # worker chunk (numeric chunk id)

If no worker file appears, the new URL() expression was not statically detected — check for a computed path, confirm the bundler version, and grep the built main bundle for the worker’s source filename: with ?worker&inline you should find a long base64 string, and with a separate chunk you should find the hashed filename. In the browser, DevTools lists live workers as separate targets, which is the fastest confirmation that the right script URL was used; Chrome DevTools Worker Debugging covers stepping into that target and checking its source maps resolved.

What a correct build looks like, and how to confirm it A production Vite build writes the entry chunk, a separately hashed worker chunk and the stylesheet into dist/assets. The worker chunk is the proof that static detection worked. With the inline suffix there is no second file, so you grep the entry chunk for a long base64 string instead. Three checks confirm the result: the hashed worker file exists, the emitted entry chunk references that exact hash, and DevTools shows the running worker as its own target with a resolved source map. dist/assets/ after vite build main-C1x9kQ.js your application entry sort.worker-DkP2fa.js the worker chunk — this is the proof index-Bd1x2f.css unrelated asset with ?worker&inline there is no second file grep the entry chunk for a long base64 string: data:text/javascript;base64,… The file exists After a production build a hashed *.worker-*.js must be sitting in dist/ — the dev server proves nothing. The entry chunk points at it grep the emitted main bundle for that hashed name, or for the base64 blob if you inlined the worker. The browser loads that URL DevTools lists a live worker as its own target, with its own resolved source map.
Three cheap checks, in order: the chunk was emitted, the entry chunk references it, and the browser actually fetched that URL. Failing the first means static detection never happened.

Performance Note

Measure the load path rather than guessing at it. The interval that matters is construction to first message, because that covers fetch, parse, isolate creation and script evaluation:

const t0 = performance.now();
const worker = new Worker(
  new URL('./sort.worker.ts', import.meta.url),
  { type: 'module' }
);
worker.addEventListener('message', function onReady() {
  console.log(`worker ready in ${(performance.now() - t0).toFixed(1)} ms`);
}, { once: true });
// In the worker: self.postMessage('ready') as the first statement.

The rule of thumb on a desktop browser: creating the isolate and evaluating a small script costs a couple of milliseconds, a warm HTTP/2 fetch of the worker chunk adds one round-trip, and a repeat visit served from disk cache costs almost nothing. An inline worker removes the request but pays for it on every page load, since base64 inflates the script by about a third before compression and those bytes sit in the critical path of the main bundle whether or not the worker is ever constructed.

That gives a clear split. Keep the separate chunk when the worker is reused across navigations or is larger than a few kilobytes — HTTP caching makes the request nearly free after the first visit, and the worker script stays out of the main bundle’s parse cost. Reach for ?worker&inline only for small workers on first-visit-dominated traffic, or where an extra request is impossible. Either way, construct the worker eagerly rather than at the moment of first use: an idle worker costs a few hundred kilobytes of isolate memory, but constructing it lazily puts the whole load path inside the user’s first interaction. If several workers are involved, hoist that decision into the sizing rules in Worker Pool Management.

Construction to first message, three startup paths A cold first visit spends most of its time on the network fetch of the worker chunk, then parse and compile, isolate creation and evaluation — roughly twenty milliseconds on a desktop browser. A repeat visit served from disk cache drops the network segment and finishes in well under half that. An inlined worker has no request at all and starts at about the same time as the warm case, but pays for it in the main bundle: base64 inflates the script by about a third and those bytes load on every page view whether or not the worker is ever constructed. milliseconds from new Worker() to the worker's first message cold, separate chunk first visit network fetch ≈ 20 ms warm, from disk cache repeat visit ≈ 8 ms inline in the bundle no request at all ≈ 8 ms the request is gone, but roughly a third more bytes ride in the main bundle on every load, worker used or not one network round-trip, recovered by the cache 0 5 10 15 20 25 30 ms fetch parse + compile isolate creation evaluate, then first message Illustrative desktop figures — the shape is what matters. Measure your own path with performance.now().
Cold startup is dominated by the one segment inlining removes — and the cache removes it too, from the second visit onwards. That is the whole trade: inline pays bundle bytes on every load to win back a request that HTTP caching would otherwise refund.

Frequently Asked Questions

Why does my Vite worker build fail with a "Cannot use import statement outside a module" error?
Vite only bundles worker files it can resolve statically — through new URL('./w.ts', import.meta.url) with a string literal, or through the ?worker import suffix. If the path is built at runtime from a variable or a template literal, Vite never adds the worker to the module graph, so the file is copied or served untouched and its bare import statements execute in a classic script context. Fix it by using a literal path, or by giving each candidate worker its own explicit new Worker(...) statement. If the error only appears in the production build and not in vite dev, check worker.format instead: the dev server always runs workers as native ES modules, while the build emits an IIFE unless you set worker: { format: 'es' }.
Can I share one copy of a module between the main thread and a worker?
Not at the JavaScript level. Vite and webpack both treat a worker as a separate entry point, so a utility imported by main.ts and worker.ts is emitted into both graphs and each thread instantiates its own module record — workers run in isolated heaps with no shared object identity. What you can share is memory: a SharedArrayBuffer gives both threads views over the same bytes, at the cost of serving Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp so the page is cross-origin isolated. For everything else, keep the shared module free of DOM dependencies so it bundles cleanly into both chunks, and accept the duplicated code — it is usually a few hundred bytes after gzip.

See also