AniUI Academy

Web Workers

Move heavy work off the main thread properly: dedicated workers, structured clone and its cost, transferables, SharedArrayBuffer, and where service workers differ.

15 min read

An import screen accepted a 40MB CSV, parsed it, validated every row and rendered a preview. On the developer's file it took 300ms. On a real customer export it locked the tab for four seconds — no spinner animation, no cancel button, nothing, because the spinner and the button were on the same thread as the parser. The parsing code was fine. It was in the wrong place.

One thread, everything on it

Your JavaScript, the DOM, style and layout, and event dispatch all share the main thread. A function that runs for four seconds does not slow the page down; it stops it. The compositor keeps scrolling a stale frame and CSS transitions on transform keep going, which is why a frozen page can still appear to scroll while every click is silently queued.

A worker is a second thread with its own event loop, its own heap, and no shared variables. The two sides communicate by message. That isolation is what makes it safe: no locks, no data races, no half-written objects.

A dedicated worker

// main.js
const worker = new Worker(new URL("./parse-worker.js", import.meta.url), {
  type: "module",
});
 
worker.postMessage({ type: "parse", text: csvText });
 
worker.addEventListener("message", (event) => {
  const { rows, errors } = event.data;
  render(rows, errors);
});
 
worker.addEventListener("error", (event) => {
  report(event.message, event.filename, event.lineno);
});
// parse-worker.js
import { parseCsv } from "./csv.js";
 
self.addEventListener("message", (event) => {
  if (event.data.type !== "parse") return;
 
  try {
    self.postMessage(parseCsv(event.data.text));
  } catch (error) {
    self.postMessage({ error: error.message });
  }
});

type: "module" gives you a module worker: real import statements instead of importScripts, and the same bundling story as the rest of your code. Chrome, Firefox and Safari all ship it now, so the classic importScripts form is legacy. The new URL(..., import.meta.url) argument is what lets Vite, webpack and friends see the worker and bundle it.

worker.terminate() kills it immediately, mid-statement, with no cleanup and no chance to finish. self.close() from inside is the polite version. Killing and respawning is a legitimate cancel mechanism — it is the only reliable way to stop a runaway loop, since there is no interrupt.

Spawning is not free: a worker costs a couple of megabytes and a few milliseconds of startup, and its module graph is loaded again from scratch. Create one per long-lived job or keep a small pool. Never create one per keystroke.

The boundary is a copy

postMessage does not pass a reference. It runs the structured clone algorithm, which deep-copies the value.

It handles far more than JSON: Map, Set, Date, RegExp, Blob, File, ArrayBuffer, typed arrays, Error, and circular references. What it does not handle:

  • Functions, of any kind. DataCloneError.
  • DOM nodes.
  • Prototypes. A class Order instance arrives as a plain object with the right fields and none of the methods.
  • Getters, which are read and copied as plain values.
  • Anything in a closure, since the closure is a function.

The cost is real and it is paid on both threads. Cloning a 50MB object graph blocks the sender while it serialises and the receiver while it deserialises, which is exactly the freeze you were trying to escape. Two rules follow: send the raw text and parse in the worker rather than parsing on the main thread and sending the objects, and send results, not intermediate state.

If a message contains something uncloneable, the send throws. If it arrives and cannot be deserialised, you get a messageerror event rather than message, which is worth listening for.

Transferables

Some objects can be moved instead of copied. Pass them in the second argument:

const pixels = new Uint8ClampedArray(width * height * 4);
 
worker.postMessage({ width, height, pixels }, [pixels.buffer]);
 
console.log(pixels.byteLength); // 0 — detached, gone from this thread

Ownership transfers. It is constant time whether the buffer is 1KB or 1GB, and the sender's view is detached — reading it throws. Transferable types include ArrayBuffer, MessagePort, ImageBitmap, OffscreenCanvas and the stream types. For image and audio work this is the difference between viable and not.

OffscreenCanvas deserves its own mention: transfer a canvas to a worker and the worker can draw to it directly, keeping the entire render loop off the main thread.

Shared memory

SharedArrayBuffer is genuinely shared: both threads see the same bytes, with no message at all. Atomics gives you the primitives to coordinate safely — Atomics.add, Atomics.compareExchange, and Atomics.wait/Atomics.notify for blocking a worker until data is ready. Atomics.wait throws on the main thread by design; use Atomics.waitAsync there.

Since Spectre it requires cross-origin isolation, because shared memory plus a counter is a high-resolution timer, and a high-resolution timer is what a cache-timing attack needs. Serve both:

Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

Then check crossOriginIsolated before assuming it works. The catch is that require-corp also breaks every cross-origin subresource that does not send Cross-Origin-Resource-Policy or opt in via CORS, so enabling it on an existing site with third-party embeds is a project, not a header. Most applications do not need it. Threaded WebAssembly and shared ring buffers do.

What a worker can reach

No window, no document, no DOM, no localStorage, no alert. The global is self, a DedicatedWorkerGlobalScope.

Available and useful: fetch, XMLHttpRequest, WebSockets, IndexedDB, the Cache API, crypto.subtle, performance, timers, OffscreenCanvas, and WebAssembly. That covers most real work. Note that IndexedDB is available, which is what makes a worker a sensible place to own a local database.

Where they earn their place

  • Parsing large files. CSV, JSON, XLSX, GeoJSON. Send the text, get rows back.
  • Image and video processing. Resize, filter, encode, with OffscreenCanvas and transferred buffers.
  • Cryptography. Key derivation is designed to be slow. Argon2 or PBKDF2 with a real iteration count belongs off the main thread.
  • Diffing and grading. Comparing two large trees, or running a test suite against submitted code, while the editor stays responsive.
  • Search and indexing. Building an inverted index over thousands of documents at startup.

Running untrusted code deserves a correction. A worker is a separate thread, not a security boundary. It runs on your origin, with your cookies reachable through fetch and your IndexedDB open to it. It stops untrusted code from hanging your UI and from touching the DOM. It does not stop it exfiltrating data. For that you need a cross-origin sandboxed iframe, and ideally a worker inside it.

Try it yourself
Loading playground...

Raise the row count until the number crosses 50ms, and you have written a long task. That is the threshold at which this work should move.

Errors and lifecycle

An uncaught throw inside a worker fires an error event on the worker object, carrying message, filename and lineno. Handle it, because otherwise a crashed worker looks identical to a slow one: the reply simply never arrives. Give every request an id and a timeout, and treat a missing reply as a failure.

Raw postMessage gets unpleasant once you have several concurrent requests. Either build a small id-to-promise map, or use Comlink, which wraps the whole thing in proxies so calling a worker function looks like calling an async function.

Service workers are a different tool

They share the word and almost nothing else. A service worker sits between your pages and the network as a programmable proxy for a scope, with an install/activate lifecycle, and it keeps running after every page is closed — it can be woken by push notifications or background sync. It is how offline support, precaching and stale-while-revalidate are built.

It is also aggressively terminated when idle, so it is a poor place for computation. Use a service worker for the network. Use a dedicated worker for CPU.

What to remember

  • The main thread owns rendering and input. Anything long there stops both.
  • Module workers with new URL(..., import.meta.url) are the modern form.
  • postMessage copies. Prototypes, functions and DOM nodes do not survive.
  • Transfer ArrayBuffers for large payloads; the sender's copy is detached afterwards.
  • SharedArrayBuffer needs COOP and COEP, and most applications do not need it.
  • A worker is an isolation boundary for the UI, not a security boundary.

Check yourself

4 questions · pass 3/4 to unlock Security in the Browser

up to 50
  1. 1.Which of these survives a postMessage to a worker?

  2. 2.You call worker.postMessage(buffer, [buffer]) with a 200MB ArrayBuffer. What is true of buffer on the sending side afterwards?

  3. 3.What does using SharedArrayBuffer require today that it did not before Spectre?

  4. 4.What is the fundamental difference between a service worker and a dedicated worker?

4 left to answer