---
title: 'Web Workers'
source: 'https://academia.sh/en/courses/browser-platform/web-workers'
course: 'The Browser and the Web Platform'
language: en
updated: '2026-08-17T18:09:11+00:00'
license: 'CC BY-SA 4.0'
---

# Web Workers

Moving computation outside the main thread; the non-shared memory model, message passing, structured cloning's scope and cost, transferable objects, worker types, and when the separation pays off.

Most of the previous lesson's capabilities produce heavy data: frames
coming from the camera, sensor readings, thousands of rows in a
selected measurement file. Processing these on the main thread
exhausts the frame budget in a single call, and the interface stops
responding.

Splitting the work into chunks spreads out the delay but does not
shorten the total time; the computation still happens on the same
thread. The second solution is taking the computation out of there
entirely. For this, the browser offers a runtime that runs in
separate threads.

## Non-Shared Memory

**A worker** is a runtime that runs on a separate thread and is not
a copy of the page. It has its own global object, its own heap, and
its own event loop. Three consequences follow from this.

**It cannot access the document tree.** Selecting a node, reading
style, adding an element is not the worker's job. The only place
that updates the interface is the main thread; the worker only
produces data.

**It shares no variables.** An object on the main thread is
invisible to the worker. The only link between them is **message
passing**. This means the single-threaded model from the
Asynchronous JavaScript and Runtime curriculum stays unbroken: both
sides are single-threaded within themselves, there is no shared
state between them, and classic race conditions do not arise.

**It consumes a separate resource.** Every worker carries its own
memory and its own runtime structure; its count does not scale up
without limit.

## Running a Worker

The example below runs with `node:worker_threads` and opens a real
thread. Its browser counterpart follows the same model: a worker
gets constructed with a script address, and both sides send and
receive messages. Naming details differ — on the browser side,
initial data gets sent as a separate message — but the flow is the
same.

```js
// worker.mjs — moving computation to a separate thread (node:worker_threads)
import { Worker, isMainThread, parentPort, workerData } from "node:worker_threads";

// The same file runs both on the main thread and in the worker; the branch splits on isMainThread.
function summarize(measurements) {
  let total = 0;
  let max = -Infinity;
  let aboveThreshold = 0;
  for (const v of measurements) {
    total += v;
    if (v > max) max = v;
    if (v > 5) aboveThreshold += 1;
  }
  return { count: measurements.length, average: total / measurements.length, max, aboveThreshold };
}

if (isMainThread) {
  // Deterministic data: the same array gets produced on every run.
  const measurements = Array.from({ length: 200000 }, (_, i) => Math.sin(i / 7) * 10);
  const worker = new Worker(new URL(import.meta.url), { workerData: measurements });

  worker.on("message", (result) => {
    console.log("result :", {
      count: result.count,
      average: Number(result.average.toFixed(6)),
      max: Number(result.max.toFixed(6)),
      aboveThreshold: result.aboveThreshold,
    });
  });
  worker.on("error", (error) => console.log("error  :", error.message));
  worker.on("exit", (code) => console.log("exit   :", code));
  console.log("main thread set up the worker and continued without waiting");
} else {
  parentPort.postMessage(summarize(workerData));
}
```

```
main thread set up the worker and continued without waiting
result : { count: 200000, average: 0.000399, max: 10, aboveThreshold: 66672 }
exit   : 0
```

The output's order is the model's summary. The main thread
constructs the worker and continues **without waiting**; the
callback runs once the result arrives. Throughout the loop over two
hundred thousand elements, the main thread is free and keeps
processing events.

The largest value in the result comes from how the sample data got
produced: the sine function's largest value approaches one, and
multiplied by ten it is ten. The above-threshold count also derives
from the data; because the data is deterministic, it comes out the
same on every run. An exit code of zero declares that the worker
closed without error.

## The Cost of Message Passing

Message passing is not free. The value sent does not get shared, it
gets **cloned**. The cloning rule is wider than JSON's and has its
own name.

```js
// clone.mjs — structured cloning and transferable objects
const record = {
  code: "T-01",
  at: new Date("2024-02-11T06:00:00Z"),
  tags: new Map([["night", true]]),
  sample: new Uint8Array([3, 1, 4]),
};
const clone = structuredClone(record);
console.log("date preserved:", clone.at instanceof Date, "| map preserved:", clone.tags instanceof Map);
console.log("array preserved:", clone.sample instanceof Uint8Array, "| same object:", clone.sample === record.sample);

// What JSON cannot do: a circular reference
const node = { name: "station" };
node.itself = node;
const nodeClone = structuredClone(node);
console.log("cycle preserved:", nodeClone.itself === nodeClone);

// Values that cannot get cloned produce an error.
for (const [name, value] of [["function", { f: () => 1 }], ["symbol", { s: Symbol("x") }]]) {
  try {
    structuredClone(value);
    console.log(name.padEnd(14), ": cloned");
  } catch (error) {
    console.log(name.padEnd(14), ":", error.name);
  }
}

// Transfer: the buffer does not get copied, ownership gets handed over.
const buffer = new ArrayBuffer(1024);
const transferred = structuredClone(buffer, { transfer: [buffer] });
console.log("after transfer source:", buffer.byteLength, "| target:", transferred.byteLength);
```

```
date preserved: true | map preserved: true
array preserved: true | same object: false
cycle preserved: true
function       : DataCloneError
symbol         : DataCloneError
after transfer source: 0 | target: 1024
```

**Structured cloning** does not suffer the JSON losses seen in the
Storage APIs lesson: a date stays a date, a map stays a map, a
circular reference gets preserved. In return, the copy is a genuine
copy; the objects on the two sides are separate objects, and a
change made on one does not reflect on the other.

**There are values that cannot get cloned.** A function and a symbol
cannot get cloned and produce an error. The same rule applies to
nodes; sending an element to a worker is not possible. This is the
message side's counterpart to not being able to access the document
tree.

**Transferable objects** make it possible to escape the cloning
cost. A buffer put in the transfer list does not get copied; its
ownership passes to the other side and it becomes unusable on the
sender's side — the source's length is zero in the output. Large
binary data gets moved this way at almost no cost, but the sending
side has to be certain it will never use it again.

There is also genuinely **shared memory**: two threads seeing the
same buffer. This path depends on conditions that require the page
to be isolated from other sources, and access to shared memory has
to get queued with a mechanism that makes operations atomic. It is
the path resorted to last, because it brings race conditions back.

## Types and Lifetime

There are three types of worker thread. **A dedicated worker**
belongs to the document that constructed it; it ends once the
document closes. **A shared worker** serves multiple documents of
the same origin and speaks through ports; it is useful for doing a
shared computation once across tabs. **A service worker**, though,
serves a separate purpose — it mediates network requests and can run
even while the document is closed; it is the Service Workers
lesson's subject.

A worker can get terminated explicitly. Termination is abrupt: the
worker's work gets left unfinished. Terminating an ongoing
computation once the user moves to another view is this context's
counterpart to the cancellation rule from previous lessons. A worker
that does not get terminated stays in memory and keeps consuming
resources.

Error handling is separate too. An uncaught error inside the worker
gets reported to the main thread with an event; a worker crashing
does not crash the page. This isolation is a gain: an unreliable
computation gets kept separate from the main thread.

## When It Is Worth It

The separation has a setup cost: opening the thread, loading the
script, cloning the data, cloning the result back. For a small job,
this cost outweighs the gain.

The test is this: is the work **pure computation**, is it **long**,
is it **independent of the document tree**. If all three conditions
hold at once, a worker pays off — parsing a large measurement file,
image processing, compression, building a search index. If one
condition fails, the previous lesson's splitting method fits better.

The data transfer itself also enters the test. If the data to get
cloned is more expensive than the computation, the gain disappears;
in that case, sending the raw data to the worker once and then
working with only small messages afterward, or using a transferable
object, is needed.

## Summary

- A worker thread has its own memory and event loop; it cannot
  access the document tree and speaks with the main thread only
  through message passing.
- The main thread does not wait after constructing the worker; the
  callback runs once the result arrives.
- In message passing, values get carried by structured cloning; the
  types and circular references JSON loses get preserved, and values
  like functions and symbols produce an error.
- Transferable objects do not get cloned, ownership gets handed
  over, and they become unusable on the sending side.
- A dedicated worker serves a document, a shared worker serves
  documents of the same origin; a service worker forms a separate
  type for network mediation.
- A worker pays off when the work is pure computation, long, and
  independent of the document tree; setup and cloning cost swallows
  the gain when these conditions are not met.

## Next Step

Throughout this topic, behavior got added to the page: events got
delegated, default behaviors got intervened on, state got stored and
written to the address, measurements got observed, computation got
taken out of the main thread. Every capability is usable on its own;
but all of them still sit scattered across the page's general code.
A measurement row's own structure, its own style, and its own
behavior are not together.

The next step in distributing the load is the interface piece
pulling itself together: an element defining its own tag, its own
lifecycle, and its own behavior in a form the browser recognizes.
The next topic begins with this packaging; its first lesson, Custom
Elements, takes up how an element with its own name gets introduced
to the browser, and which hooks get called as it enters and leaves
the tree.
