Web Workers, SharedWorkers, Service Workers, and Worklets all support ES Modules. Move heavy computation off the main thread while using the same import/export syntax you already know.
Create a module worker by passing { type: 'module' } to the Worker constructor. Inside the worker, you can use standard import/export statements:
// main.js - Create a module worker
const worker = new Worker('./worker.js', { type: 'module' });
worker.onmessage = (e) => {
console.log('Result:', e.data);
};
worker.postMessage({ numbers: [1, 2, 3, 4, 5] });
// worker.js - ES Module worker
import { sum, average } from './math-utils.js';
import { formatResult } from './formatting.js';
self.onmessage = (e) => {
const { numbers } = e.data;
// Use imported modules just like in any ESM file
const total = sum(numbers);
const avg = average(numbers);
self.postMessage(formatResult({ total, avg }));
};
// math-utils.js - Shared module (works in both main thread and workers)
export function sum(arr) {
return arr.reduce((a, b) => a + b, 0);
}
export function average(arr) {
return sum(arr) / arr.length;
}
Key Advantage:
Module workers can share code with your main thread using standard imports. No need to duplicate utility functions or inline everything into one worker file. The browser handles module resolution and caching.
SharedWorkers run a single instance shared across multiple browser tabs or iframes. They support ES Modules with the same { type: 'module' } option:
// main.js - Connect to a shared module worker
const shared = new SharedWorker('./shared.js', { type: 'module' });
shared.port.onmessage = (e) => {
console.log('Connected clients:', e.data.count);
};
shared.port.start();
shared.port.postMessage({ action: 'getCount' });
// shared.js - SharedWorker with ES Modules
import { createStore } from './store.js';
const store = createStore({ clients: 0 });
const ports = new Set();
self.onconnect = (e) => {
const port = e.ports[0];
ports.add(port);
store.update('clients', ports.size);
port.onmessage = (msg) => {
if (msg.data.action === 'getCount') {
port.postMessage({ count: store.get('clients') });
}
};
port.start();
};
Use Cases for SharedWorkers:
Service Workers can use ES Modules for caching strategies, offline support, and background sync. Pass { type: 'module' } during registration:
// Register a module service worker
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js', {
type: 'module',
scope: '/'
});
}
// sw.js - Service Worker as an ES Module
import { CacheStrategy } from './cache-strategy.js';
import { CACHE_NAME, STATIC_ASSETS } from './config.js';
const strategy = new CacheStrategy(CACHE_NAME);
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => cache.addAll(STATIC_ASSETS))
);
});
self.addEventListener('fetch', (event) => {
event.respondWith(
strategy.networkFirst(event.request)
);
});
Browser Support Note:
ESM Service Workers are supported in Chrome 91+, Edge 91+, and Safari 16.4+. Firefox does not yet support module service workers. For broad compatibility, consider bundling your service worker or using classic (non-module) service worker format.
// cache-strategy.js - Reusable caching module
export class CacheStrategy {
constructor(cacheName) {
this.cacheName = cacheName;
}
async networkFirst(request) {
try {
const response = await fetch(request);
const cache = await caches.open(this.cacheName);
cache.put(request, response.clone());
return response;
} catch {
return caches.match(request);
}
}
async cacheFirst(request) {
const cached = await caches.match(request);
if (cached) return cached;
const response = await fetch(request);
const cache = await caches.open(this.cacheName);
cache.put(request, response.clone());
return response;
}
async staleWhileRevalidate(request) {
const cache = await caches.open(this.cacheName);
const cached = await cache.match(request);
const fetchPromise = fetch(request).then(response => {
cache.put(request, response.clone());
return response;
});
return cached || fetchPromise;
}
}
Worklets are lightweight, specialized workers that always use ES Modules. They run on dedicated rendering threads for audio, paint, and animation:
// Register an AudioWorklet module
const audioCtx = new AudioContext();
await audioCtx.audioWorklet.addModule('./audio-processor.js');
const processorNode = new AudioWorkletNode(audioCtx, 'my-processor');
source.connect(processorNode).connect(audioCtx.destination);
// audio-processor.js - AudioWorklet module
import { applyGain } from './audio-utils.js';
class MyProcessor extends AudioWorkletProcessor {
process(inputs, outputs) {
const input = inputs[0];
const output = outputs[0];
for (let channel = 0; channel < output.length; channel++) {
applyGain(input[channel], output[channel], 0.5);
}
return true; // Keep processor alive
}
}
registerProcessor('my-processor', MyProcessor);
// Register a PaintWorklet module
CSS.paintWorklet.addModule('./checkerboard.js');
// checkerboard.js - PaintWorklet module
class CheckerboardPainter {
static get inputProperties() {
return ['--checker-size', '--checker-color'];
}
paint(ctx, size, properties) {
const cellSize = parseInt(properties.get('--checker-size')) || 20;
const color = properties.get('--checker-color').toString() || '#000';
for (let y = 0; y < size.height; y += cellSize) {
for (let x = 0; x < size.width; x += cellSize) {
if ((x / cellSize + y / cellSize) % 2 === 0) {
ctx.fillStyle = color;
ctx.fillRect(x, y, cellSize, cellSize);
}
}
}
}
}
registerPaint('checkerboard', CheckerboardPainter);
/* Usage in CSS */
.element {
--checker-size: 20;
--checker-color: #3b82f6;
background-image: paint(checkerboard);
}
// Register an AnimationWorklet module
await CSS.animationWorklet.addModule('./scroll-animator.js');
// scroll-animator.js
class ScrollAnimator {
animate(currentTime, effect) {
// Runs on the compositor thread - no jank!
effect.localTime = currentTime * 0.5;
}
}
registerAnimator('scroll-animator', ScrollAnimator);
Why Worklets Use Modules:
Worklets were designed after ES Modules became standard, so they use modules by default - there is no classic/non-module option. The addModule() method always loads an ES Module file.
The standard way to communicate with workers. Data is structurally cloned (deep copied) by default:
// main.js - Structured clone (default)
const worker = new Worker('./worker.js', { type: 'module' });
// Send data - object is deep cloned
worker.postMessage({
action: 'process',
data: { items: [1, 2, 3], config: { sort: true } }
});
// Receive results
worker.onmessage = ({ data }) => {
console.log(data.result); // Processed data
};
For large binary data, transfer ownership instead of copying to avoid memory duplication:
// Transfer an ArrayBuffer (zero-copy)
const buffer = new ArrayBuffer(1024 * 1024); // 1MB
console.log(buffer.byteLength); // 1048576
// Transfer ownership to worker - no copy!
worker.postMessage({ buffer }, [buffer]);
console.log(buffer.byteLength); // 0 (transferred)
// Worker side
self.onmessage = ({ data }) => {
const { buffer } = data;
const view = new Float64Array(buffer);
// Process the data...
// Transfer it back
self.postMessage({ buffer }, [buffer]);
};
// Other transferable types:
// - OffscreenCanvas
// - ImageBitmap
// - MessagePort
// - ReadableStream / WritableStream
Comlink by Google Chrome Labs eliminates manual postMessage handling. Call worker functions as if they were local:
// worker.js - Expose an API with Comlink
import { expose } from 'comlink';
const api = {
async processImage(imageData) {
// Heavy computation runs off main thread
const result = applyFilters(imageData);
return result;
},
fibonacci(n) {
if (n <= 1) return n;
return api.fibonacci(n - 1) + api.fibonacci(n - 2);
}
};
expose(api);
// main.js - Call worker like a local object
import { wrap } from 'comlink';
const worker = new Worker('./worker.js', { type: 'module' });
const api = wrap(worker);
// Calls are async and transparent
const result = await api.processImage(imageData);
const fib = await api.fibonacci(40);
console.log(fib); // 102334155 (computed off main thread)
When to Use Comlink:
Comlink shines when your worker has many functions or a class-based API. For simple one-off messages, raw postMessage is fine. Comlink adds about 1.1KB gzipped and handles proxying, transferables, and error propagation automatically.
| Worker Type | Chrome | Firefox | Safari | Edge |
|---|---|---|---|---|
| Dedicated Worker (module) | 80+ | 114+ | 15+ | 80+ |
| SharedWorker (module) | 80+ | No | 16+ | 80+ |
| Service Worker (module) | 91+ | No | 16.4+ | 91+ |
| AudioWorklet | 66+ | 76+ | 14.1+ | 79+ |
| CSS PaintWorklet | 65+ | No | No | 79+ |
| AnimationWorklet | 71+ | No | No | 71+ |
Pass { type: 'module' } as the second argument when creating a Worker: new Worker('./worker.js', { type: 'module' }). Inside the worker file, you can then use import and export statements just like in any ES Module. This is supported in Chrome 80+, Safari 15+, Edge 80+, and Firefox 114+.
Dedicated module workers are supported in Chrome 80+, Edge 80+, Safari 15+, and Firefox 114+. SharedWorker modules have similar support except Firefox. Service Worker modules are supported in Chrome 91+ and Safari 16.4+ but not yet in Firefox.
Module workers move heavy computation off the main thread, keeping the UI responsive. With ES Modules, workers can import shared utility code without duplicating it, use top-level await for async initialization, and benefit from the browser's module caching.
Comlink is a library by Google Chrome Labs that wraps the postMessage API to provide an RPC-like interface. Instead of manually handling message events, you expose functions from the worker and call them directly from the main thread as if they were local async functions.