JavaScriptWeb Workers

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

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

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.

Cloning has a cost
Sending a 100 MB `ArrayBuffer` clones 100 MB by default. For large binary data, use the **transferable** form — the buffer changes ownership instead of being copied.

transferring instead of copying

JS
const buf = new ArrayBuffer(1024 * 1024 * 100);
worker.postMessage({ buf }, [buf]);
// buf.byteLength on the main thread is now 0 — ownership moved
Two-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

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

JS
// inside the worker
import { compute } from "./math.js";

self.addEventListener("message", (e) => {
  self.postMessage(compute(e.data));
});
Terminating

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

Comlink
Writing manual postMessage plumbing gets old. Libraries like Google's [Comlink](https://github.com/GoogleChromeLabs/comlink) make a worker look like an async object — `const api = Comlink.wrap(worker); await api.compute(x)`. Worth knowing about for non-trivial worker code.