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.
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.
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
},
});
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.
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', '...'],
},
},
},
};
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.
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.
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.
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.
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.
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.