Bundling Module Workers with Vite and webpack
Modern build tools must statically analyse worker entry points at compile time — they cannot discover them at runtime. Getting this right unlocks tree-shaking, TypeScript type-checking, and source maps inside your workers.
This page is part of the Inline Workers vs Dedicated Workers guide, itself a section of the broader Web Workers Architecture & Communication reference.
Minimal Reproducible Example
The following snippet works correctly in both Vite ≥ 3 and webpack 5 without any additional plugins:
// 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 bundler config changes. Both tools detect the worker automatically because new URL(…, import.meta.url) is a statically analysable expression.
Line-by-Line Walkthrough
const worker = new Worker(
new URL('./sort.worker.ts', import.meta.url),
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
// 1. new URL(literal, import.meta.url) is the key idiom.
// The string './sort.worker.ts' MUST be a string literal —
// no variables, no template literals.
// import.meta.url resolves to the current module's URL,
// so the path is relative to *this* file, not the project root.
{ type: 'module' }
// ^^^^^^^^^^^^^^^^
// 2. { type: 'module' } tells the browser to load the worker
// as an ES module, enabling top-level import/export inside it.
// Without this, the worker runs as a classic script and bare
// import statements throw a SyntaxError at runtime.
);
Inside the worker:
self.onmessage = (e: MessageEvent<number[]>) => {
// 3. TypeScript infers the correct Worker global scope (WorkerGlobalScope)
// when the file is referenced via the new URL() idiom — no special
// tsconfig.json "lib" entry is needed beyond "webworker".
const sorted = [...e.data].sort((a, b) => a - b);
// 4. Spread creates a copy; do NOT mutate e.data directly.
// Sorting in place would still work here, but mutating the
// structured-clone copy is surprising to readers.
self.postMessage(sorted);
// 5. Returns a plain Array — structured clone serialises it.
// For large arrays (>100 k elements), prefer a Float64Array
// returned as a transferable to avoid clone cost.
};
Vite-Specific Worker Imports
Vite extends the standard idiom with import-suffix shortcuts:
// Approach A — typed worker module (Vite only)
import SortWorker from './sort.worker.ts?worker';
const worker = new SortWorker(); // equivalent to new Worker(url, { type: 'module' })
// Approach B — inline worker (data URL, no separate network request)
import InlineSortWorker from './sort.worker.ts?worker&inline';
const inlineWorker = new InlineSortWorker();
// The worker script is base64-encoded into the main bundle.
// Use only for workers < ~8 KB to avoid inflating the initial load.
The ?worker suffix tells Vite’s module graph to emit the worker as a separate chunk with its own hash for long-term caching. The ?worker&inline variant skips that separate request and encodes the worker script as a data URL — useful in environments that block additional network requests (strict CSPs, Electron renderers with file:// origins) but at the cost of a larger main bundle.
The ?worker and ?worker&inline suffixes are Vite-specific transform flags. TypeScript does not know about them by default. Add /// <reference types="vite/client" /> to any file that uses them, or include "vite/client" in tsconfig.json's compilerOptions.types array.
webpack 5 Native Worker Support
webpack 5 (shipped October 2020) added first-class support for the same new URL() idiom — no worker-loader plugin required:
// webpack.config.js — no special rule needed for this pattern
// The bundler detects new Worker(new URL(..., import.meta.url)) automatically.
// main.ts
const worker = new Worker(
new URL('./sort.worker.ts', import.meta.url),
{ type: 'module' }
);
webpack emits the worker script as a separate chunk, applies code-splitting, and injects the correct __webpack_public_path__ so the worker URL resolves regardless of deployment prefix. If you still use worker-loader, remove it — mixing the old plugin with the native idiom produces duplicate bundles.
// webpack.config.js — if you need to customise the output filename
module.exports = {
output: {
filename: '[name].[contenthash].js',
// Workers inherit this naming convention automatically.
// No separate entry point declaration is required.
},
};
As of webpack 5.49, the { type: 'module' } option in the Worker constructor is an experimental flag in the browser. webpack bundles the worker as a classic IIFE by default for maximum compatibility. You can opt in to module output by setting experiments.outputModule: true in webpack.config.js, but be aware that Chrome 80+, Firefox 114+, and Safari 15+ are required.
Comparison: Vite vs webpack Worker Bundling
| Feature | Vite (?worker) |
Vite (new URL()) |
webpack 5 (new URL()) |
|---|---|---|---|
| Automatic chunk split | Yes | Yes | Yes |
| Inline (no extra request) | ?worker&inline |
No | No |
| TypeScript support | Requires vite/client ref |
Built-in | Built-in |
| Source maps inside worker | Yes | Yes | Yes (devtool option) |
| Module worker output | ES module | ES module | IIFE (module opt-in) |
| Dynamic import in worker | Yes | Yes | Yes |
| Plugin required | No | No | No (webpack ≥ 5) |
Gotchas & Edge Cases
1. Dynamic URL strings break static analysis
// BAD — neither Vite nor webpack can detect this worker
const name = condition ? './a.worker.ts' : './b.worker.ts';
const worker = new Worker(new URL(name, import.meta.url), { type: 'module' });
// Results in: worker file not bundled, 404 in production
// GOOD — keep separate explicit statements
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' });
2. Blob workers bypass bundler optimisation entirely
Classic inline workers built from a Blob URL are invisible to the bundler. The script string is emitted as-is — no tree-shaking, no TypeScript compilation, no source maps. If you need a bundler-processed inline worker, use Vite’s ?worker&inline suffix instead.
// BAD — raw Blob worker: no bundler processing
const code = `import { heavyFn } from './lib.ts'; self.onmessage = …`;
const blob = new Blob([code], { type: 'application/javascript' });
// heavyFn is NOT bundled; this throws at runtime in production
const worker = new Worker(URL.createObjectURL(blob));
3. import.meta.url is undefined in CommonJS
If your build emits CommonJS modules (e.g. "module": "commonjs" in tsconfig.json without a bundler transform), import.meta.url does not exist. This pattern requires ESM output ("module": "esnext" or "module": "es2022"). When using Create React App (webpack 4), you need worker-loader or a CRA customisation — the native idiom only lands with webpack 5.
4. MIME type mismatch with dev servers
Some development servers (particularly custom Express setups or older Vite proxy configurations) serve .ts files with text/plain instead of application/javascript. Module workers are subject to strict MIME type enforcement. Ensure your dev server serves .ts files via the bundler’s module resolution pipeline rather than as raw text.
A dedicated module worker loaded via new URL() benefits from HTTP caching across page loads. After the first visit, the worker script is served from disk cache in under 1 ms, versus 5–15 ms for first-parse plus V8 isolate startup. For workers that run on every page load, the ?worker&inline approach eliminates the request entirely but adds ~33% to base64 encoding overhead — only worth it for scripts under ~8 KB.
TypeScript Configuration for Module Workers
Your tsconfig.json needs the "webworker" lib entry so self, postMessage, and WorkerGlobalScope are typed correctly:
{
"compilerOptions": {
"lib": ["es2022", "dom", "webworker"],
"module": "esnext",
"moduleResolution": "bundler",
"target": "es2022"
}
}
If your project mixes main-thread and worker files in the same tsconfig.json, you will see collisions between Window (lib: dom) and WorkerGlobalScope (lib: webworker). The cleanest resolution is a separate tsconfig.worker.json that excludes "dom" and includes only "webworker", referenced from your bundler config:
// tsconfig.worker.json
{
"extends": "./tsconfig.json",
"compilerOptions": {
"lib": ["es2022", "webworker"]
},
"include": ["src/**/*.worker.ts"]
}
Vite picks this up automatically when the ?worker suffix is used. For webpack, point the ts-loader or babel-loader rule for *.worker.ts files at this alternate config.
Verifying the Bundle Output
After building with Vite (vite build) or webpack (webpack --mode production), confirm the worker chunk exists:
# Vite output (dist/)
dist/assets/main.abc123.js # main bundle
dist/assets/sort.worker.def456.js # worker chunk — present ✓
# webpack output (dist/)
dist/main.abc123.js
dist/sort.worker.def456.js
If the worker file is absent, the new URL() literal was not statically detected — check for dynamic string construction, confirm your bundler version (webpack ≥ 5.0, Vite ≥ 3.0), and ensure { type: 'module' } is passed to the constructor.