Lesson 27 of 28
Web Workers
Move heavy work off the main thread properly: dedicated workers, structured clone and its cost, transferables, SharedArrayBuffer, and where service workers differ.
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 Orderinstance 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 threadOwnership 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-corpThen 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
OffscreenCanvasand 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.
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. postMessagecopies. Prototypes, functions and DOM nodes do not survive.- Transfer
ArrayBuffers for large payloads; the sender's copy is detached afterwards. SharedArrayBufferneeds 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
1.Which of these survives a
postMessageto a worker?2.You call
worker.postMessage(buffer, [buffer])with a 200MBArrayBuffer. What is true ofbufferon the sending side afterwards?3.What does using
SharedArrayBufferrequire today that it did not before Spectre?4.What is the fundamental difference between a service worker and a dedicated worker?
4 left to answer