How to Bundle Web Workers with Vite and Webpack
This guide covers the build side of Offloading Work to Web Workers with Comlink, part of Core Web Vitals & Measurement. A worker only helps INP if it starts quickly and does not drag a second copy of your application into the browser. Both depend on how it is bundled.
Older setups used worker-loader, string-concatenated blob URLs, or a hand-copied public/worker.js. Modern bundlers recognise a single standard pattern — new Worker(new URL('./worker.js', import.meta.url), { type: 'module' }) — and emit the worker as its own hashed chunk with its own dependency graph. Getting the details right decides whether the worker chunk is 6KB or 300KB, whether it is cached across deploys, and whether it shares code with the main bundle or duplicates it.
Rapid Diagnosis
- Inspect the build output. List emitted chunks; the worker should be its own file with a content hash. If it is missing, it was probably inlined as a blob or not bundled at all.
- Check the worker chunk size. Open it in a bundle analyser. Large frameworks or UI libraries inside a worker chunk mean an import pulled in application code.
- Check for duplication. If a large library (a parser, a search engine) appears in both the main and worker chunks, the user downloads it twice.
- Check headers. The worker chunk should be served with the same long-lived
Cache-Control: immutablepolicy as other hashed assets. - Check start timing. In the Network panel, note when the worker script is requested relative to LCP and first interaction.
Root Cause Analysis
1. Non-standard worker creation. Building a worker from a string or a URL the bundler cannot analyse (new Worker('/workers/' + name + '.js')) bypasses bundling: no hashing, no tree-shaking, and often no transpilation.
2. Shared modules importing heavy code. The worker imports a utils module that imports the store, which imports the UI library. The worker bundle inherits the whole chain.
3. Inlined workers. Plugins that inline workers as base64 or blob URLs bloat the main bundle and defeat separate caching.
4. Classic workers in a module codebase. type: 'classic' workers cannot use import; teams work around it with importScripts of unbundled files.
Step-by-Step Resolution
1. Use the standard worker pattern
// Works in Vite, webpack 5, Rollup (with plugin), esbuild-based tools and Parcel.
const worker = new Worker(new URL('./search-worker.js', import.meta.url), {
type: 'module',
name: 'search', // shows as the thread name in DevTools
});
// trade-off: module workers are supported in all current browsers, but very old
// Safari versions lacked them. If you must support those, configure the bundler
// to emit a classic worker format (Vite: worker.format = 'iife').
Expected outcome: the bundler emits search-worker-[hash].js, cached immutably and fetched only when the worker is created.
2. Configure worker output explicitly
// vite.config.js
export default {
worker: {
format: 'es', // module worker output
rollupOptions: { output: { entryFileNames: 'workers/[name]-[hash].js' } },
},
};
// webpack.config.js (webpack 5 detects the pattern natively)
module.exports = {
output: { chunkFilename: '[name].[contenthash].js' },
optimization: { splitChunks: { chunks: 'all' } }, // allows sharing with workers
};
// trade-off: splitChunks 'all' can create many small shared chunks that the
// worker must fetch before starting. Check the request count for the worker's
// first load and set minSize if it fragments too much.
Expected outcome: predictable, hashed worker files in a known location.
3. Keep the worker's import graph lean
Import specific modules rather than barrels, and keep anything DOM- or UI-related out of code the worker imports. A lint rule can enforce the boundary.
// .eslintrc.cjs — forbid UI imports in worker code.
module.exports = { overrides: [{
files: ['src/workers/**'],
rules: { 'no-restricted-imports': ['error', { patterns: ['react*', 'vue', '@/components/*', '@/store/*'] }] },
}] };
// trade-off: lint rules catch direct imports only. A worker importing a module
// that transitively imports React is still possible; check the bundle analyser
// output in CI for framework code in worker chunks.
Expected outcome: worker chunks contain only the computation they exist for — typically tens of kilobytes. Why barrel files break tree shaking explains the underlying problem.
4. Start the worker at the right moment
Create the worker after LCP and before the first interaction that needs it — on idle, on hover or focus of the triggering control, or after load.
let workerPromise;
export function getWorker() {
workerPromise ??= Promise.resolve(new Worker(new URL('./search-worker.js', import.meta.url), { type: 'module' }));
return workerPromise;
}
addEventListener('load', () => requestIdleCallback(() => getWorker()));
searchInput.addEventListener('focus', () => getWorker(), { once: true });
// trade-off: creating on idle wastes a download for users who never search.
// If search usage is rare, create on focus only and accept a short delay on
// the first query.
Expected outcome: the worker is warm when needed, without competing with LCP resources.
Verification
Build and inspect: the worker chunk is separate, hashed, small, and free of framework code. In DevTools, the worker appears as a named thread in the Performance panel, and its script is fetched after LCP. Add a CI check on worker chunk size with size-limit so an innocent import does not quietly add 200KB.
// .size-limit.cjs
module.exports = [
{ name: 'search worker', path: 'dist/workers/search-worker-*.js', limit: '20 KB' },
];
// trade-off: a byte limit catches accidental imports but not slow code; pair it
// with a timing check of the worker's startup in a throttled lab run.
Deployment Pitfalls
Content Security Policy. A worker-src (or fallback script-src) directive must allow the origin serving worker chunks. Blob-URL workers additionally need blob: in the policy, which weakens it — another reason to prefer bundled worker files.
Cross-origin CDNs. Workers must be same-origin with the page. If your static assets live on a separate CDN hostname, new Worker(url) with a cross-origin URL fails. Serve worker chunks from the page's origin (a CDN path rule) or bootstrap with a small same-origin script that import()s the cross-origin module, which requires CORS headers.
Stale workers after deploy. A long-lived tab holds a worker from the previous release; if the main thread updates (via a service worker or a soft reload of modules) and the protocol between them changed, messages break. Version your worker message protocol and recreate the worker when versions differ.
Testing Workers in CI
Unit tests for worker logic are easiest when the computation lives in plain modules the worker imports: test those modules directly in Node, and keep the worker file itself a thin Comlink.expose wrapper. For integration, run a headless browser test that creates the real bundled worker, sends a representative request and asserts the response and its timing. This catches the bundling-specific failures — wrong output format, CSP blocking the worker URL, a missing chunk — that unit tests never exercise.
FAQ
Does Vite's ?worker import suffix still matter?
import MyWorker from './w.js?worker' is a Vite-specific shortcut that produces a constructor. It works, but the new URL(..., import.meta.url) pattern is portable across bundlers and is what Vite recommends for most cases. Use ?worker&inline only when you genuinely need a single-file build.
Can a worker share a chunk with the main thread?
Yes, with module workers both can import the same hashed chunk, and the browser's HTTP cache serves it to the second requester. They still each evaluate it separately — there is no shared module instance across threads — so sharing saves bytes, not CPU.
How do I debug a bundled worker?
Source maps work for workers when the bundler emits them; in DevTools, worker sources appear under their own thread in the Sources panel. Naming the worker via the name option makes the thread easy to find in both Sources and Performance.
Related
- Comlink vs raw postMessage for workers — the messaging layer on top of the bundled worker.
- Running client-side search in a Web Worker — a worker built with this setup.
- Configuring manualChunks in Vite — controlling how shared code is split.