JavaScript Web Workers Cheat Sheet
Covers creating dedicated workers, message passing with postMessage, transferable objects, and the limits of the worker global scope.
Creating & Messaging a Worker
Spawn a worker and exchange messages from the main thread.
// main.js - create and communicate with a workerconst worker = new Worker('worker.js');worker.postMessage({ cmd: 'start', payload: 42 }); // send data to workerworker.onmessage = (event) => { console.log('Result from worker:', event.data); // receive data};worker.onerror = (err) => { console.error('Worker error:', err.message);};
Inside the Worker Script
The worker's own scope has no DOM but can compute and respond.
// worker.js - runs on a separate threadself.onmessage = (event) => { const { cmd, payload } = event.data; if (cmd === 'start') { const result = payload * 2; // heavy computation here self.postMessage(result); }};self.onerror = (err) => console.error(err);
Module Workers
Load ES modules inside a worker with the type option.
// Module worker (ES modules inside a worker)const worker = new Worker('worker.js', { type: 'module' });// worker.jsimport { heavyCalc } from './math.js';self.onmessage = (e) => self.postMessage(heavyCalc(e.data));
Worker Types & Scope
Key APIs and worker categories you'll encounter.
- Dedicated Worker- Single script owned by one main thread; created with new Worker(url)
- Shared Worker- Can be accessed by multiple scripts/tabs from the same origin via SharedWorker(url)
- Service Worker- Proxy between app and network, enables offline caching and push notifications
- self- Reference to the worker's own global scope (WorkerGlobalScope)
- importScripts()- Synchronously loads one or more scripts into a classic (non-module) worker
- postMessage()- Sends a structured-cloned message to the other side of the channel
- terminate()- Immediately stops a worker from the main thread; no cleanup runs
- close()- Called inside a worker to stop itself
- No DOM access- Workers cannot touch window, document, or the DOM directly
Transferable Objects
Move large binary data between threads without copying.
// Transferable objects avoid copying large binary dataconst buffer = new ArrayBuffer(1024 * 1024); // 1MBworker.postMessage({ buf: buffer }, [buffer]); // ownership transferred, not copied// buffer.byteLength is now 0 in the main thread after transfer
Worker Pool for Parallel Tasks
Distribute CPU-bound jobs across a fixed pool of workers to avoid spawning one per task.
class WorkerPool { constructor(url, size = navigator.hardwareConcurrency || 4) { this.queue = []; this.workers = Array.from({ length: size }, () => { const w = new Worker(url); w.busy = false; return w; }); } run(payload) { return new Promise((resolve, reject) => { this.queue.push({ payload, resolve, reject }); this._dispatch(); }); } _dispatch() { const worker = this.workers.find(w => !w.busy); if (!worker || this.queue.length === 0) return; const { payload, resolve, reject } = this.queue.shift(); worker.busy = true; worker.onmessage = (e) => { worker.busy = false; resolve(e.data); this._dispatch(); }; worker.onerror = (e) => { worker.busy = false; reject(e); this._dispatch(); }; worker.postMessage(payload); }}const pool = new WorkerPool('crunch.js');const results = await Promise.all([1, 2, 3].map(n => pool.run(n)));
OffscreenCanvas for Off-Main-Thread Rendering
Move canvas drawing work to a worker so it never blocks the main thread's frame budget.
// main.jsconst canvas = document.querySelector('canvas');const offscreen = canvas.transferControlToOffscreen(); // canvas becomes worker-ownedconst worker = new Worker('render-worker.js');worker.postMessage({ canvas: offscreen }, [offscreen]);// render-worker.jsself.onmessage = (e) => { const ctx = e.data.canvas.getContext('2d'); function frame() { ctx.clearRect(0, 0, 300, 150); ctx.fillRect(Math.random() * 300, 40, 10, 10); requestAnimationFrame(frame); // rAF works inside workers too } frame();};
Lifecycle & Error Propagation Gotchas
Edge cases that trip people up once past the basic postMessage flow.
- messageerror event- Fires instead of onmessage when structured cloning fails to deserialize the payload
- Uncaught worker exceptions- Bubble up as an ErrorEvent on worker.onerror; call event.preventDefault() to stop it also logging to the console
- terminate() is abrupt- No finally blocks or cleanup handlers run inside the worker; prefer a self.close() message for graceful shutdown
- Structured clone limits- Functions, DOM nodes, and Symbols cannot be posted; they throw a DataCloneError
- Nested workers- A worker can spawn its own sub-workers, but debugging and termination cascades get harder to reason about
- Same-origin scripts only- new Worker(url) requires the script to be same-origin unless served with permissive CORS and loaded as a module
- No synchronous XHR blocking main- Workers can use synchronous XHR without freezing the UI, since they run off the main thread
Prefer transferable objects (like ArrayBuffer) over structured cloning for large binary payloads - postMessage(data, [buffer]) moves ownership instead of copying, which is dramatically faster for big datasets.