Web Workers
JavaScript on a page runs on a single thread — the main thread — which also handles user input, layout, and painting. A long-running computation freezes everything: clicks queue up, animations stutter, the page feels broken. Web Workers give you another thread you can offload heavy work to, with one strict rule: workers cannot touch the DOM. They communicate with the page only through messages.
The setup
A worker is a separate JavaScript file. You instantiate it from the main thread.
main.js
const worker = new Worker(new URL("./hash.worker.js", import.meta.url), {
type: "module",
});
worker.addEventListener("message", (e) => {
console.log("hash:", e.data);
});
worker.postMessage({ text: "hello, world" });hash.worker.js
self.addEventListener("message", async (e) => {
const bytes = new TextEncoder().encode(e.data.text);
const buf = await crypto.subtle.digest("SHA-256", bytes);
const hex = [...new Uint8Array(buf)]
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
self.postMessage(hex);
});Inside the worker, self is the global. There is no window, no document. Many useful things are still there — fetch, crypto, timers, WebSocket, indexedDB, atob, console.
postMessage and structured clone
Messages are not shared references. They are copied through the structured clone algorithm, which handles most JavaScript values:
Primitives, plain objects, arrays.
Date,RegExp,Map,Set,Blob,File,ArrayBuffer, typed arrays.Circular references.
What does not survive the clone:
Functions, classes, DOM nodes, Errors with custom properties.
Anything with prototype methods — only the data survives, not the type.
transferring instead of copying
const buf = new ArrayBuffer(1024 * 1024 * 100);
worker.postMessage({ buf }, [buf]);
// buf.byteLength on the main thread is now 0 — ownership movedTwo-way communication
The same postMessage / onmessage shape works in both directions. For request/response use a correlation id; for streams, just keep posting.
promise-style RPC over postMessage
let nextId = 1;
const pending = new Map();
worker.addEventListener("message", (e) => {
const { id, result, error } = e.data;
const entry = pending.get(id);
if (!entry) return;
pending.delete(id);
error ? entry.reject(new Error(error)) : entry.resolve(result);
});
function call(method, args) {
const id = nextId++;
return new Promise((resolve, reject) => {
pending.set(id, { resolve, reject });
worker.postMessage({ id, method, args });
});
}Module workers and imports
Modern workers are ES modules — pass { type: "module" } to the constructor and you get import / export instead of importScripts.
// inside the worker
import { compute } from "./math.js";
self.addEventListener("message", (e) => {
self.postMessage(compute(e.data));
});Terminating
worker.terminate(); // from the main thread, hard kill self.close(); // inside the worker, graceful shutdown
Real use cases
Heavy compute — image processing, audio analysis, encryption, parsing large JSON / CSV.
Background sync — keeping a long-running computation alive while the user keeps clicking.
Off-main-thread state — persistence, IndexedDB queries, large in-memory caches.
Library sandboxing — running untrusted code (e.g. a code playground) in isolation.
When NOT to use a worker
Workers help when CPU is the bottleneck. They do not help with I/O — fetch is already non-blocking on the main thread. Splitting trivially fast code into a worker costs more in postMessage overhead than the computation itself. Benchmark before assuming.
Other worker types
Dedicated workers (this page) — one owner, one thread, simple postMessage.
Shared workers — one thread shared between multiple tabs of the same origin. Limited browser support.
Service workers — a different beast: a network proxy that survives across pages and powers offline apps. Covered on its own page.