This chapter follows Finding memory leaks. Everything in the series so far happened on one thread. This chapter is about running JavaScript on several. It is a focused article on workers: dedicated workers in browsers first, then the other kinds, then worker_threads in Node.
What a worker is
A worker is a separate agent: its own thread, call stack, heap, garbage collector, and event loop. It runs its own script and shares no variables with the page that created it. The only way in or out is a message.
That isolation is what makes workers safe. No code on the main thread can see a half-updated object in a worker, so the run-to-completion guarantee from The event loop, tasks, and microtasks holds in each thread separately.
Inside a browser worker there is no window and no DOM. The global object is self, and most non-DOM APIs are available: fetch, timers, WebSocket, IndexedDB, crypto, and OffscreenCanvas. Code is loaded with import in module workers and with importScripts() in classic ones.
Workers are not free. Each one starts a new JavaScript environment and loads its script. In Node 24, starting four workers until each had sent a first message took about 30 ms and added roughly 40–50 MB to the process’s memory. That is a good trade for a computation that takes hundreds of milliseconds, and a bad one for work that takes two.
Starting a dedicated worker
A dedicated worker belongs to the page that created it. Module workers, created with type: 'module', can use import like any other module:
const worker = new Worker(new URL('./stats.worker.js', import.meta.url), { type: 'module',});
worker.addEventListener('message', (event) => { renderStats(event.data);});
worker.addEventListener('error', (event) => { console.error('Worker failed:', event.message);});
worker.postMessage({ values: readings });self.addEventListener('message', (event) => { const { values } = event.data; const sorted = values.toSorted((a, b) => a - b); const mean = values.reduce((sum, value) => sum + value, 0) / values.length;
self.postMessage({ mean, median: sorted[Math.floor(sorted.length / 2)] });});new URL('./stats.worker.js', import.meta.url) resolves the worker’s path relative to the current module, and bundlers such as Vite and webpack recognize this pattern and bundle the worker file. worker.terminate() stops the worker immediately; inside the worker, self.close() does the same.
An uncaught exception in the worker fires error on the Worker object in the page. It does not crash the page, but it also does not reject any promise you are waiting on, so a request/response wrapper has to handle errors explicitly. The section on worker pools shows one way.
Messages and structured clone
postMessage copies its argument with the structured clone algorithm. The receiver gets a deep copy; changes on one side are invisible on the other. Structured clone handles more than JSON:
| Copied | Not copied |
|---|---|
| Objects, arrays, primitives (except symbols) | Functions — throws DataCloneError |
Map, Set, Date, RegExp, BigInt |
DOM nodes |
ArrayBuffer, typed arrays, Blob, File |
Symbols |
Error objects |
Getters and setters, which arrive as plain values |
| Cyclic references | Prototypes and class identity |
The last row catches people out. A class instance arrives as a plain object with the same own properties and no methods:
class Point { constructor(x, y) { this.x = x; this.y = y; } length() { return Math.hypot(this.x, this.y); }}
worker.postMessage(new Point(3, 4));// in the worker: event.data is { x: 3, y: 4 }, and event.data.length is undefinedSend data, and rebuild objects with behavior on the receiving side. The same algorithm is available directly as structuredClone(value), which is the standard way to deep-copy data within one thread.
Copying costs time proportional to the size of the data, on both threads. Sending a large structure to a worker for a small computation can take longer than doing the computation on the main thread.
Transferring instead of copying
Some objects can be transferred: ownership moves to the receiver, and no bytes are copied. List them in the second argument of postMessage:
const pixels = new Uint8ClampedArray(width * height * 4);
worker.postMessage({ pixels, width, height }, [pixels.buffer]);
console.log(pixels.buffer.byteLength); // 0After the transfer, the sender’s buffer is detached: its length is 0, and any typed array viewing it is empty. The worker has the only usable copy until it transfers the buffer back. Transferring a buffer of hundreds of megabytes takes about as long as transferring a few bytes.
Transferable types include ArrayBuffer, MessagePort, ImageBitmap, OffscreenCanvas, and the stream types ReadableStream, WritableStream, and TransformStream. Transferring an OffscreenCanvas lets a worker draw to a canvas on the page without involving the main thread at all.
Transfer works well when work moves in stages: the main thread fills a buffer, transfers it to a worker, and the worker transfers the result back.
Worker pools
Workers run in parallel only up to the number of CPU cores. More workers than cores compete for the same cores, and each costs startup time and memory. Size a pool from the number of logical processors, minus one for the main thread: navigator.hardwareConcurrency in browsers, os.availableParallelism() in Node. Start the workers once and send them jobs, instead of starting a worker per job.
Messages have no built-in notion of request and response. A small client that tags each request with an ID gives each call its own promise:
export function createWorkerClient(url) { const worker = new Worker(url, { type: 'module' }); const pending = new Map(); let nextId = 0;
worker.addEventListener('message', ({ data }) => { const request = pending.get(data.id); pending.delete(data.id); if (data.error) request.reject(new Error(data.error)); else request.resolve(data.result); });
return { call(method, ...args) { const id = nextId++; const { promise, resolve, reject } = Promise.withResolvers(); pending.set(id, { resolve, reject }); worker.postMessage({ id, method, args }); return promise; }, terminate() { worker.terminate(); }, };}const methods = { sum: (values) => values.reduce((total, value) => total + value, 0),};
self.addEventListener('message', ({ data }) => { const { id, method, args } = data; try { self.postMessage({ id, result: methods[method](...args) }); } catch (error) { self.postMessage({ id, error: error.message }); }});const math = createWorkerClient(new URL('./math.worker.js', import.meta.url));const total = await math.call('sum', [1, 2, 3]);Libraries package the same idea: Comlink exposes a worker’s functions as async functions in the page, and Piscina provides a worker pool for Node.
Shared memory with SharedArrayBuffer and Atomics
A SharedArrayBuffer is not copied or transferred when posted. Both sides get a view of the same memory, and a write on one thread is visible to the others. That avoids copying entirely and brings the problems of shared-memory threads with it: two threads incrementing the same value with counter[0]++ will lose updates, because read and write are separate steps.
Atomics provides operations that are indivisible across threads:
const shared = new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT);const counter = new Int32Array(shared);
const workers = Array.from({ length: 4 }, () => new Worker(new URL('./count.worker.js', import.meta.url), { type: 'module' }),);
for (const worker of workers) { worker.postMessage(shared);}self.addEventListener('message', ({ data }) => { const counter = new Int32Array(data); for (let i = 0; i < 1_000_000; i++) { Atomics.add(counter, 0, 1); } self.postMessage('done');});With Atomics.add, the counter ends at exactly 4,000,000. Atomics.wait blocks a thread until another calls Atomics.notify on the same location, which is the building block for locks and queues. Browsers do not allow Atomics.wait on the main thread, because it would freeze the page; use Atomics.waitAsync there, which returns a promise.
Browsers make SharedArrayBuffer available only on cross-origin isolated pages, because shared memory allows the precise timers that Spectre-style attacks need. A page is isolated when it is served with both headers:
Cross-Origin-Opener-Policy: same-originCross-Origin-Embedder-Policy: require-corprequire-corp blocks cross-origin resources that do not opt in with a Cross-Origin-Resource-Policy header or CORS. Cross-Origin-Embedder-Policy: credentialless is a less strict alternative that loads such resources without cookies. Check self.crossOriginIsolated at runtime before creating a SharedArrayBuffer. Node has no such restriction.
Other kinds of workers
Browsers have four kinds of worker-like contexts, each for a different job:
- Dedicated worker — belongs to one page and lives until it is terminated or the page closes. Use it for CPU-heavy computation for that page.
- Shared worker — one instance shared by every same-origin page that connects to it. Use it for a connection or state that several tabs should share.
- Service worker — registered for an origin; the browser starts and stops it as needed. Use it for intercepting requests, offline caching, and push notifications.
- Worklet — runs inside a rendering or audio pipeline. Use it for audio processing (
AudioWorklet) or custom painting (CSS.paintWorklet).
A service worker is not a place for computation. The browser stops it when it is idle, often after about 30 seconds, which discards any state held in variables, and one instance serves every page in its scope. It is a network proxy for those pages. Shared worker support varies between browsers, especially on mobile, so check it for your targets before relying on one.
Worker threads in Node
Node’s worker_threads module follows the same model: separate V8 instances in one process, with postMessage, structured clone, transfer, MessageChannel, and SharedArrayBuffer. Input can also be passed at startup as workerData:
import { Worker, isMainThread, parentPort, workerData } from 'node:worker_threads';import { createHash } from 'node:crypto';
if (isMainThread) { const worker = new Worker(new URL(import.meta.url), { workerData: { input: 'hello', rounds: 100_000 }, }); worker.once('message', (digest) => console.log(digest)); worker.once('error', (error) => console.error(error));} else { let digest = workerData.input; for (let i = 0; i < workerData.rounds; i++) { digest = createHash('sha256').update(digest).digest('hex'); } parentPort.postMessage(digest);}The same file runs on both threads; isMainThread decides which branch runs. resourceLimits, for example { maxOldGenerationSizeMb: 256 }, caps a worker’s heap so that a runaway job ends that worker instead of the whole process.
Before adding workers to a Node service, check whether the work already runs off the main thread. Node’s asynchronous file system, dns.lookup, zlib, and CPU-heavy crypto functions such as pbkdf2 and scrypt run in libuv’s native thread pool, four threads by default and configurable with UV_THREADPOOL_SIZE. Calling their asynchronous versions keeps the event loop free without any worker. Workers are for computation written in JavaScript.
Node has two other ways to use more cores. child_process runs a separate process, which gives stronger isolation — a crash or memory leak stays in that process — at a higher startup and communication cost. The cluster module runs several copies of a server process that share one port, which scales request handling rather than individual computations; see Load balancing an HTTP server with cluster module.
Next: JavaScript engine glossary.
Check your understanding
Question 1 of 8
0 of 8 checked