Skip to content
JavaScript js browser 5 min read

Web Workers

JavaScript in the browser runs on a single thread, which means a long-running calculation freezes everything: the page stops responding, animations stutter, and clicks queue up until the work finishes. Web Workers solve this by running scripts on a separate background thread that runs in parallel with the main thread. Workers communicate by passing messages, never by sharing memory directly, so the UI stays smooth while heavy work happens elsewhere.

What a worker can and cannot do

A worker runs in its own global scope (DedicatedWorkerGlobalScope), completely isolated from the page. It has no access to the window object, the document, or the DOM. It cannot read or write the page directly, alert the user, or touch localStorage. What it can do is run pure JavaScript, use fetch, XMLHttpRequest, timers, IndexedDB, the crypto API, and import other scripts with importScripts().

This restriction is the whole point: because workers can’t touch shared mutable UI state, there are no data races over the DOM. All coordination goes through messages.

Available in a workerNot available in a worker
fetch, XMLHttpRequestdocument / DOM
setTimeout / setIntervalwindow (use self instead)
importScripts(), ES moduleslocalStorage / sessionStorage
IndexedDB, caches, cryptoalert / prompt / confirm
postMessage, onmessagedirect access to page variables

Creating a worker and exchanging messages

You create a worker by pointing the Worker constructor at a script URL. Both sides talk through postMessage() to send data and an onmessage handler (or an addEventListener("message", ...) listener) to receive it. The actual payload arrives on the event’s data property.

// main.js — runs on the page
const worker = new Worker("worker.js");

worker.postMessage({ type: "sum", upTo: 1_000_000_000 });

worker.onmessage = (event) => {
  console.log("Result from worker:", event.data);
};

worker.onerror = (err) => {
  console.error("Worker failed:", err.message);
};
// worker.js — runs on its own thread
self.onmessage = (event) => {
  const { type, upTo } = event.data;

  if (type === "sum") {
    let total = 0;
    for (let i = 0; i < upTo; i++) total += i;
    self.postMessage(total); // send the answer back
  }
};

Output:

Result from worker: 499999999500000000

That loop would lock a normal page for seconds. Inside a worker it runs in the background, and the main thread keeps painting and responding to input the entire time.

Tip: Modern bundlers support new Worker(new URL("./worker.js", import.meta.url), { type: "module" }). The type: "module" option lets the worker use import/export instead of importScripts().

Structured clone: how data crosses the thread boundary

Data passed to postMessage() is not shared — it is copied using the structured clone algorithm. This is more capable than JSON.stringify: it handles Date, RegExp, Map, Set, ArrayBuffer, typed arrays, and even circular references. It cannot copy functions, DOM nodes, or class instances with their prototypes.

// All of these survive the trip; functions/DOM nodes throw a DataCloneError.
worker.postMessage({
  when: new Date(),
  pattern: /\d+/g,
  tags: new Set(["a", "b"]),
  buffer: new Uint8Array([1, 2, 3]),
});

For large binary payloads, copying is wasteful. You can instead transfer ownership of an ArrayBuffer, handing the memory to the other thread with zero copy. After transfer the buffer is detached (length 0) on the sending side.

const data = new Uint8Array(50_000_000);
// Second argument lists transferable objects.
worker.postMessage(data, [data.buffer]);
console.log(data.length); // 0 — ownership moved to the worker

Use cases: heavy computation kept off the UI

Workers shine for anything CPU-bound that would otherwise block rendering — image filtering, parsing large files, cryptographic hashing, data compression, physics simulations, or running a syntax highlighter over a huge document. The demo below hashes input on a worker while the spinner keeps animating, proving the main thread never stalls.

<button id="run">Hash 5,000,000 iterations</button>
<span id="spinner">⏳</span>
<pre id="out"></pre>
<script>
  // Build the worker from an inline Blob so this pen is self-contained.
  const workerCode = `
    self.onmessage = (e) => {
      let h = 0;
      for (let i = 0; i < e.data; i++) {
        h = (h * 31 + i) >>> 0;
      }
      self.postMessage(h);
    };
  `;
  const url = URL.createObjectURL(
    new Blob([workerCode], { type: "application/javascript" })
  );
  const worker = new Worker(url);

  const spinner = document.getElementById("spinner");
  let frame = 0;
  // This animation never freezes, even while the worker is busy.
  setInterval(() => {
    spinner.textContent = ["⏳", "⌛"][frame++ % 2];
  }, 250);

  document.getElementById("run").onclick = () => {
    document.getElementById("out").textContent = "Working…";
    worker.postMessage(5_000_000);
  };
  worker.onmessage = (e) => {
    document.getElementById("out").textContent = "Hash: " + e.data;
  };
</script>

Terminating workers

Workers do not stop on their own — they live until you end them. You can terminate from the page with worker.terminate(), which kills the thread immediately without letting it finish current work. A worker can also shut itself down from the inside by calling self.close(). Always clean up workers you no longer need, since each one holds a real OS thread and its own memory.

// From the page: hard stop, no chance to finish.
worker.terminate();
// From inside the worker: graceful self-shutdown after a job.
self.onmessage = (event) => {
  const result = doWork(event.data);
  self.postMessage(result);
  self.close(); // no further messages will be processed
};

The interactive panel below lets you start a never-ending counting worker and stop it on demand, showing how terminate() reclaims the thread.

<button id="start">Start counting</button>
<button id="stop">Terminate</button>
<pre id="count">idle</pre>
<script>
  const code = `
    let n = 0;
    setInterval(() => self.postMessage(++n), 50);
  `;
  const url = URL.createObjectURL(
    new Blob([code], { type: "application/javascript" })
  );
  let worker = null;
  const out = document.getElementById("count");

  document.getElementById("start").onclick = () => {
    if (worker) return;
    worker = new Worker(url);
    worker.onmessage = (e) => (out.textContent = "count: " + e.data);
  };
  document.getElementById("stop").onclick = () => {
    if (!worker) return;
    worker.terminate(); // thread stops instantly
    worker = null;
    out.textContent += " (terminated)";
  };
</script>

Best Practices

  • Reserve workers for genuinely CPU-bound work; the message round-trip and clone cost can outweigh the benefit for trivial tasks.
  • Use a type field (or similar) in your message payloads so a single worker can handle multiple kinds of jobs cleanly.
  • Transfer ArrayBuffers instead of copying when moving large binary data — it avoids duplicating megabytes of memory.
  • Always attach an onerror handler so a thrown error inside the worker doesn’t fail silently.
  • Call terminate() (or self.close()) when a worker is no longer needed to free its thread and memory.
  • Prefer { type: "module" } workers so you can use ES imports and share code with your main bundle.
  • For pools of repeated tasks, reuse one long-lived worker rather than spawning a new one per job to avoid startup overhead.
Last updated June 1, 2026
Was this helpful?