Web Workers & Off-Main-Thread Work

Advanced · 20 min read · ▶ live playground · ✦ checkpoint

Web Workers let a browser page run some JavaScript away from the main UI thread. They are not something you need for every app, but they solve one very specific problem: heavy work that would otherwise make clicks, typing, scrolling, and animation feel stuck.

You already know the core reason from the event-loop lesson: one stack, one thread, one thing at a time. Workers give you another thread for computation, but they make you communicate by messages instead of sharing the page directly.

The main thread is where the page breathes

The main thread is the browser thread that runs your page's JavaScript and keeps the page interactive. It handles event callbacks, DOM updates, layout, painting, Canvas drawing, and requestAnimationFrame callbacks.

When the main thread is busy, the page cannot respond. A click may be queued, but it cannot run. A canvas animation may have a next frame scheduled, but it cannot draw. The browser may want to repaint, but your long function is still sitting on the call stack.

That visible stuck feeling is called jank. Jank is not the same as "the computer is slow" in some vague way. In browser JavaScript, it often means a task ran too long on the main thread, so the browser missed chances to handle input or paint the next frame.

There are three common responses:

  • Do less work.
  • Break work into smaller chunks so the main thread gets breaks.
  • Move work to a Worker when the computation is large and independent enough.

The first response is the best when you can get it. Workers are a tool, not a badge.

Try it - blocking work versus chunked work

This playground does not create a Worker. Instead, it shows why Workers exist. Run blocking work does all units in one task, so the progress bar jumps only after the task finishes. Run chunked work does the same total units in small pieces with short breaks between them, so the page can update progress.

web page — live previewsandboxed
Run rebuilds the page

Chunking is useful when work can be split and partial progress is acceptable. A Worker is the next step when the work is still expensive enough that sharing the main thread feels bad.

A Worker is another JavaScript thread

A Web Worker is a separate JavaScript environment that runs alongside the page. The page creates it, sends it messages, and listens for messages back.

Main page code usually looks like this:

const worker = new Worker("./score-worker.js", { type: "module" });
 
worker.postMessage({
  type: "score",
  players: [
    { name: "Mina", points: 42 },
    { name: "Rae", points: 37 },
  ],
});
 
worker.addEventListener("message", (event) => {
  renderScoreSummary(event.data);
});
 
worker.addEventListener("error", () => {
  showStatus("The background calculation failed.");
});

The worker file listens with self because it is not running inside the page's window:

self.addEventListener("message", (event) => {
  const total = event.data.players.reduce((sum, player) => {
    return sum + player.points;
  }, 0);
 
  self.postMessage({
    type: "score-ready",
    total,
  });
});

This is message passing. The page and the worker do not call each other's functions directly. They send data, and each side reacts to a message event.

That may feel indirect, but it is the point. The worker can spend time on computation without holding the page's call stack hostage. The main page stays responsible for rendering, input, and user feedback.

Workers cannot touch the DOM

A Worker does not have document, cannot call querySelector, cannot update a button, and cannot draw directly into a normal DOM canvas. The page owns the DOM. The worker owns background computation.

So this is the wrong mental model:

// In a worker file: this is not allowed.
document.querySelector("#status").textContent = "Done";

The correct shape is:

// In the worker:
self.postMessage({ type: "done", message: "Done" });
 
// In the page:
worker.addEventListener("message", (event) => {
  status.textContent = event.data.message;
});

You have already benefited from this design idea in small ways throughout the course. Code examples run in isolated environments so experimental code does not take over the lesson page. Workers are the browser feature that gives application code a similar separation when background computation belongs away from the UI.

Structured clone copies messages

Worker messages use the structured clone algorithm. Beginner version: the browser copies many ordinary JavaScript values for you, including objects, arrays, strings, numbers, booleans, Maps, Sets, and ArrayBuffers. It does not copy functions or DOM nodes.

That means this kind of data is message-shaped:

worker.postMessage({
  filters: new Map([["team", "studio"]]),
  ids: [101, 102, 103],
  includeArchived: false,
});

But a DOM element is not:

worker.postMessage({
  button: document.querySelector("button"),
});

For large binary data, browsers also support transferables. A transferable moves ownership instead of copying the bytes:

const buffer = new ArrayBuffer(1024);
worker.postMessage({ buffer }, [buffer]);

You do not need transferables for everyday UI state. They matter for image processing, audio data, large files, and other byte-heavy work where copying would be expensive.

When to reach for a Worker

Use a Worker when the work is CPU-heavy, independent of the DOM, and large enough to hurt responsiveness. Good candidates include image processing, parsing a large file, searching a large local dataset, compressing data, generating a report, or running repeated calculations for a visualization.

Do not reach for a Worker just because code is asynchronous. Fetch already waits off to the side while the network happens. A timer callback is not automatically heavy. Small array transformations are usually fine on the main thread. And if the worker has to send tiny updates back after every line of work, message overhead can erase the benefit.

The practical question is:

Can the main page describe the job as data, let another thread compute, and render one or a few results when they come back?

If yes, a Worker may fit. If no, first try simpler options: reduce the work, cache a result, batch DOM updates, or split the task into small chunks like the playground did.

Part IV now has the pieces for a real browser app: DOM, events, fetch, storage decisions, state-driven rendering, routing, observers, canvas, and background-work boundaries. Next, you will combine those pieces into the Real Web App project.

Checkpoint

Answer all three to mark this lesson complete

+50 XP on completion