ES Modules Logo ESModules.com

Workers & ESM

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.

Dedicated Workers with Modules

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.

SharedWorker Modules

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:

  • Shared WebSocket connection across tabs (one connection, multiple consumers)
  • Cross-tab state synchronization (shopping cart, authentication state)
  • Shared cache or data layer to reduce memory usage across tabs

Service Worker Modules

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.

Caching Patterns with Modules

// 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

Worklets are lightweight, specialized workers that always use ES Modules. They run on dedicated rendering threads for audio, paint, and animation:

AudioWorklet

// 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);

CSS PaintWorklet (Houdini)

// 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);
}

AnimationWorklet

// 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.

Communication Patterns

postMessage Basics

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
};

Transferable Objects

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 (RPC-like Communication)

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.

Browser Support

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+

Frequently Asked Questions