Web Workers

This section documents modern ECMAScript and core browser JavaScript APIs — it is not tied to any specific framework or library (React, Vue, Angular, etc.). This content was generated with the assistance of AI and should be verified against the current ECMAScript specification and MDN documentation before relying on it in production, since JavaScript language features and browser API support continue to evolve.

This section’s bibliography lists the reference material consulted while preparing these pages.

JavaScript in the browser runs on a single thread shared with layout, painting, and every event handler on the page. async/await and Promises (see Asynchronous JavaScript) keep that one thread from blocking on I/O, but they do nothing for CPU-bound work — a tight loop parsing a large file, running a physics simulation, or syntax-highlighting code on every keystroke still runs to completion before the browser can handle the next click, keypress, or repaint. Web Workers are the browser’s answer: a way to run JavaScript on a genuinely separate thread, at the cost of giving up direct access to the Window and Document objects and communicating with the main thread only through asynchronous message passing. This page covers the dedicated Worker in depth — the primary API, and the one this section’s source material (David Flanagan’s JavaScript: The Definitive Guide, §15.13, "Worker Threads and Messaging") treats at length — plus brief coverage of SharedWorker. It closes with an overview of service workers, a related but distinct background-worker type that the book gives only a passing mention (it appears only in the "further reading" list at the end of the chapter); that section is written from general/official (MDN) knowledge and is called out explicitly.

Why the UI Thread Needs Help

Because JavaScript is single-threaded, the browser guarantees that two event handlers never run at the same time and that a timer never fires while a handler is already running. That guarantee is what makes it safe to manipulate the DOM without locks or race conditions — but it comes with a corollary: a JavaScript function that runs too long ties up the event loop, and the page stops responding to input, scrolling, and animation for as long as that function keeps running. fetch(), timers, and Promises solve this for operations that are waiting on something external (a network response, a clock), but none of them make a genuinely CPU-intensive computation faster or less blocking — there is no await for "please don’t block the UI while I sum a million numbers."

A Worker sidesteps the problem by moving that computation onto its own thread entirely. Workers live in a self-contained execution environment with their own global object and no access to the Window or Document — concurrent modification of the DOM from two threads is still impossible, but a worker can run as long as it needs to without ever stalling the main thread’s event loop. Creating a worker is not free (it is heavier than scheduling a callback, though far lighter than opening a new browser window/tab), so workers suit computationally intensive or frequently repeated work — image processing, data crunching, live syntax highlighting — rather than trivial one-off tasks.

Creating a Dedicated Worker

new Worker(url) starts a new thread running the JavaScript file at url:

let dataCruncher = new Worker("utils/cruncher.js");

A relative URL is resolved against the document that called the constructor; an absolute URL must share the same origin (protocol, host, and port) as that document — workers cannot be loaded cross-origin. The file begins executing immediately, top to bottom, in a brand-new global environment isolated from the page that created it.

Sending and Receiving Messages

The only way data crosses between the main thread and a worker is by message passing. postMessage() sends a value; the receiving side gets it as the data property of a "message" event:

// Main thread -> worker
dataCruncher.postMessage("/api/data/to/crunch");

// Main thread listens for replies
dataCruncher.onmessage = function (e) {
  let stats = e.data; // the message is the event's `data` property
  console.log(`Average: ${stats.mean}`);
};
// ...or, equivalently, using the standard EventTarget API:
dataCruncher.addEventListener("message", (e) => console.log(e.data));

Every value passed to postMessage() is copied with the structured clone algorithm rather than passed by reference or serialized to a string: objects, arrays, typed arrays, `Map`s, `Set`s, and `Date`s all survive the trip intact, but functions, DOM nodes, and class instances with prototype methods do not (a cloned class instance loses its prototype and becomes a plain object). The worker and the main thread never share memory — the clone is a completely independent copy on the receiving side, which is exactly what makes concurrent access from two threads safe.

Inside the worker file, the same shape works in reverse — postMessage() and onmessage are effectively global inside a worker, since they belong to the worker’s global object:

// Inside utils/cruncher.js
self.onmessage = function (e) {
  let url = e.data;
  let result = crunchTheNumbers(url); // however long this takes, the page stays responsive
  postMessage(result); // sends the result back to the main thread
};

Terminating a Worker

A worker can be stopped from either side. From the main thread, worker.terminate() forces the worker to stop immediately, abandoning whatever it was doing:

dataCruncher.terminate();

From inside the worker, the global close() function has the same effect, letting a worker shut itself down once it knows it has no more useful work to do:

// Inside the worker, once finished:
close();

There is no property on Worker that reports whether the worker is still running, so a worker that closes itself should coordinate that decision with the main thread (e.g. by sending a final "done" message first) rather than closing silently.

Inside the Worker: WorkerGlobalScope

The global object inside a worker is a WorkerGlobalScope, not a Window — there is no document, no DOM, and no synchronous access to anything on the page that created the worker. It does, however, carry a useful subset of what Window offers:

  • self, a reference to the global object itself (there is no window property inside a worker).

  • The timer functions setTimeout(), clearTimeout(), setInterval(), clearInterval().

  • A read-only location object describing the URL the worker was loaded from.

  • A navigator object with a subset of properties (appName, appVersion, platform, userAgent, onLine).

  • fetch(), the console object, and the IndexedDB API (see Browser Storage) — all fully usable from inside a worker.

  • The Worker() constructor itself, so a worker can spawn its own nested sub-workers.

Importing Code

Workers predate JavaScript modules, so classic (non-module) workers use a dedicated loading function instead of import. importScripts() is a global, synchronous function available inside every worker:

// Runs top to bottom before the worker does anything else
importScripts("utils/Histogram.js", "utils/BitSet.js");

Each URL is resolved relative to the worker’s own script (not the page that created it), and the files are loaded and executed one after another, in order; a network or execution error aborts any scripts still pending. Because importScripts() blocks only the worker’s own thread, using a synchronous, blocking call here does not stall the main thread’s event loop the way it would if called from a page script.

Modern browsers also support module workers: passing { type: "module" } as the second argument to the Worker() constructor makes the worker’s file interpreted as an ES module, allowing import declarations directly in place of importScripts():

let worker = new Worker("worker.js", { type: "module" });

See Modules for the import/export syntax itself.

Error Handling

An uncaught exception inside a worker fires an "error" event, first on the worker’s own global object and — if that handler does not call preventDefault() on the event — again on the Worker object in the main thread:

// Inside the worker
self.onerror = function (e) {
  console.log(`Error in worker at ${e.filename}:${e.lineno}: ${e.message}`);
  e.preventDefault(); // stops propagation to the main thread's Worker object
};

// In the main thread
worker.onerror = function (e) {
  console.log(`Error in worker at ${e.filename}:${e.lineno}: ${e.message}`);
};

An unhandled Promise rejection inside a worker behaves the same way it does on a page: register a handler by assigning self.onunhandledrejection or listening for the "unhandledrejection" event.

MessageChannel and Transferable Objects

The postMessage()/onmessage pair shown so far is really a thin wrapper over a pair of automatically created MessagePort objects that page code cannot see directly. Creating a MessageChannel explicitly gives access to a fresh, independent pair of connected ports — useful for opening a second, dedicated channel (e.g. a "high-priority" channel separate from ordinary traffic) or for letting two workers talk to each other directly instead of relaying every message through the main thread:

let channel = new MessageChannel();
let myPort = channel.port1;
let yourPort = channel.port2;

myPort.postMessage("Can you hear me?");
yourPort.onmessage = (e) => console.log(e.data); // logs "Can you hear me?"

If a port is used with addEventListener() instead of onmessage, call port.start() explicitly — without it, messages queue up but are never delivered.

postMessage() also accepts an optional second argument: an array of transferable objects (MessagePort`s and `ArrayBuffer`s) to hand off to the other side instead of cloning. A transferred `ArrayBuffer becomes unusable on the sending side immediately, avoiding an expensive copy for large binary payloads such as image or audio sample data (see Arrays & Typed Arrays for ArrayBuffer and typed arrays):

let buffer = new ArrayBuffer(1024 * 1024); // 1 MB, expensive to copy
worker.postMessage({ command: "process", data: buffer }, [buffer]); // transferred, not cloned
// `buffer` is now unusable in this thread -- ownership moved to the worker

Message-Passing Sequence

The diagram below traces the full lifecycle of a dedicated worker handling one unit of work: creation, a message sent in, a result sent back, and eventual termination.

sequenceDiagram participant Main as Main thread participant Worker as Worker thread Main->>Worker: new Worker(url) activate Worker Note over Worker: runs top-to-bottom,
then waits for messages Main->>Worker: postMessage(data) Note over Worker: onmessage handler runs,
does CPU-intensive work Worker-->>Main: postMessage(result) Note over Main: onmessage handler runs,
UI thread was never blocked Main->>Worker: terminate() deactivate Worker

SharedWorker

A SharedWorker is a variant of Worker that multiple browsing contexts — separate tabs, windows, or <iframe>`s — can connect to as long as they share the same origin. Where a plain `Worker belongs to the single page that created it, a single SharedWorker instance is reused across every page that asks for it by the same script URL, which makes it a natural fit for coordinating state across tabs (a shared WebSocket connection, an in-memory cache, cross-tab notifications) without routing everything through localStorage events or a server round trip (see Browser Storage).

The API differs slightly from a dedicated worker because each connecting page gets its own MessagePort rather than talking to the worker directly:

// Main thread, in any tab:
let worker = new SharedWorker("shared-cache.js");
worker.port.start();
worker.port.postMessage("hello");
worker.port.onmessage = (e) => console.log(e.data);
// Inside shared-cache.js:
self.onconnect = function (e) {
  let port = e.ports[0]; // the port for this particular connecting page
  port.onmessage = (e) => {
    port.postMessage(`echo: ${e.data}`);
  };
};

SharedWorker support is less consistent across browsers (notably weaker on mobile) than the plain Worker, so treat it as a progressive enhancement rather than something every browser is guaranteed to run.

Service Workers (General/Official Knowledge)

The book’s coverage of service workers is thin — they appear only as a single bullet point in the "further reading" list at the end of chapter 15, without a worked example. Everything in this section is written from general/official MDN knowledge rather than the book, and is deliberately kept at an overview level rather than a full Progressive Web App tutorial.

A service worker is a special-purpose worker that sits between a web application and the network, positioned to intercept, inspect, and respond to the requests that application makes — rather than being created to run one page’s background computation, it is registered once and then persists in the browser independently of any single page being open. That makes it the mechanism behind offline support, background sync, and push notifications in modern web apps. Service workers require HTTPS (or localhost during development), since a script with this much power over a site’s network traffic is a serious target for tampering.

Registration

A page opts into a service worker explicitly, by registering the script that should manage it:

if ("serviceWorker" in navigator) {
  navigator.serviceWorker
    .register("/sw.js")
    .then((registration) => console.log("Service worker registered:", registration.scope))
    .catch((error) => console.error("Registration failed:", error));
}

The Install/Activate Lifecycle

Unlike a dedicated worker, a service worker goes through a distinct lifecycle managed by the browser rather than by application code:

  • install fires once, the first time the browser sees this service worker script (or a byte-different updated version of it). This is the conventional place to pre-cache the application’s core assets using the Cache API.

  • activate fires once installation succeeds and the service worker is ready to take control — a typical place to clean up caches left behind by a previous version of the worker.

  • fetch fires for every network request the pages under this worker’s scope make, for as long as the worker is active, letting it inspect the request and decide how to respond.

// Inside sw.js
const CACHE_NAME = "app-shell-v1";
const CORE_ASSETS = ["/", "/styles.css", "/app.js"];

self.addEventListener("install", (event) => {
  event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.addAll(CORE_ASSETS)));
});

self.addEventListener("activate", (event) => {
  // Remove caches from older versions of this worker
  event.waitUntil(
    caches.keys().then((keys) =>
      Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key)))
    )
  );
});

Intercepting Requests with fetch

The fetch event is what makes offline behavior possible: the handler can answer from the cache instead of — or before — going to the network, and can populate the cache with fresh responses as they come in:

self.addEventListener("fetch", (event) => {
  event.respondWith(
    caches.match(event.request).then((cached) => cached || fetch(event.request))
  );
});

event.respondWith() is what gives the service worker control over the response the page actually receives — without calling it, the request just falls through to the network as normal. Because installation, caching, and request interception are all asynchronous, install/activate handlers wrap their work in event.waitUntil() so the browser knows not to advance the lifecycle (or terminate the worker) until that work finishes; see Asynchronous JavaScript for the underlying Promise mechanics both methods build on.

Choosing the Right Worker Type

Type Scope Typical use

Worker

One page/tab; created and owned by the script that instantiated it.

Offloading a CPU-intensive, self-contained computation (parsing, image processing, syntax highlighting).

SharedWorker

Shared across every same-origin tab/window that connects to it.

Coordinating state or a single shared connection (WebSocket, cache) across multiple open tabs.

ServiceWorker

An entire origin’s registered scope, independent of any page being open.

Offline caching, intercepting network requests, background sync, push notifications.

All three keep application logic off the main UI thread and communicate exclusively through asynchronous message passing rather than shared memory — the same trade-off, at three different scopes, that makes the rest of this page’s postMessage()/onmessage model worth learning once and reusing everywhere.