Skip to content
academia.sh

Lesson 13 / 24

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.

Contents

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.

// 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.

// 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.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close