W3docs

JavaScript Web Workers

Learn JavaScript Web Workers to run code on a background thread and keep the UI responsive during heavy work. Communicate with postMessage.

JavaScript runs your code on a single main thread — the same thread that lays out the page, paints pixels, and handles clicks and keystrokes. That single thread is the heart of the event loop: it picks up one task, runs it to completion, then moves on. So when a function does something genuinely heavy — crunching a large array, parsing a multi-megabyte file, hashing a password thousands of times — the loop is stuck inside that function. Nothing else can happen: scroll stutters, buttons stop responding, the page appears frozen until the work finishes.

Web Workers solve this by running a script on a separate background thread, in parallel with the main thread. The heavy work moves off the critical path, the UI stays responsive, and the two threads talk to each other by passing messages.

Why Timers Are Not Enough

A common first instinct is to wrap slow work in setTimeout and hope it runs "in the background." It doesn't. Timers only defer a task to a later turn of the same loop — when the callback finally fires, it still runs on the main thread and still blocks everything while it executes. (See scheduling with setTimeout and setInterval for how that queue actually works.)

// This still freezes the page — it just freezes it 50ms later.
setTimeout(() => {
  let total = 0;
  for (let i = 0; i < 5_000_000_000; i++) total += i;
  console.log(total);
}, 50);

A Web Worker is different: its code executes on a different thread entirely, so the main thread is free to keep rendering and responding while the worker grinds away.

Creating a Worker

A worker is a separate JavaScript file. You create one by pointing the Worker constructor at that file's URL:

const worker = new Worker('worker.js');

The browser spins up a new thread, downloads worker.js, and starts running it. From that point on, the two scripts communicate only through messages — they share no variables, no functions, and no objects.

Here is the smallest complete pair of files.

main.js

const worker = new Worker('worker.js');

worker.postMessage('Hello from the main thread');

worker.onmessage = (event) => {
  console.log('Main received:', event.data);
};

worker.js

self.onmessage = (event) => {
  console.log('Worker received:', event.data);
  self.postMessage('Hello back from the worker');
};

Messaging with postMessage

Communication is bidirectional and asynchronous. The main thread calls worker.postMessage(data) and listens with worker.onmessage; inside the worker, self.postMessage(data) sends back and self.onmessage receives. Each handler gets a MessageEvent, and the payload lives on its .data property.

The data you send is copied, not shared, using the structured clone algorithm. That means you can pass strings, numbers, booleans, arrays, plain objects, Map, Set, Date, ArrayBuffer, and more — but not functions, DOM nodes, or class instances with methods. Because it's a copy, mutating an object on one side never affects the other.

Here is a full round trip. The worker computes the n-th Fibonacci number with the naive recursive algorithm — deliberately slow, exactly the kind of work that would jank the UI if it ran on the main thread.

main.js

const worker = new Worker('worker.js');

worker.onmessage = (event) => {
  console.log(`fib(${event.data.n}) = ${event.data.result}`);
};

// The page stays interactive while this runs on the worker thread.
worker.postMessage({ n: 42 });

worker.js

function fib(n) {
  return n < 2 ? n : fib(n - 1) + fib(n - 2);
}

self.onmessage = (event) => {
  const { n } = event.data;
  const result = fib(n);
  self.postMessage({ n, result });
};

The main thread fires off the request and immediately returns to handling clicks and rendering. When the worker finishes, its result arrives as a message — no freeze, no spinner stutter.

The Worker Global Scope

Inside a worker there is no window. The global object is self (a DedicatedWorkerGlobalScope), and crucially there is no document and no DOM. A worker cannot read or change the page; if it needs the UI updated, it sends a message and lets the main thread do it.

Warning

Code inside a Web Worker cannot touch the DOM. There is no document, no window, and no access to page elements. Anything visual must be handed back to the main thread via postMessage. This restriction is what makes workers safe to run in parallel — there's no shared UI state to corrupt.

Workers are far from empty, though. The worker scope gives you plenty of useful APIs:

  • importScripts('a.js', 'b.js') to load classic scripts synchronously.
  • fetch and XMLHttpRequest for network requests.
  • Timers: setTimeout, setInterval.
  • console, crypto, TextEncoder / TextDecoder, WebSocket, IndexedDB, and many more.

That makes workers a great home for network fetching, parsing, compression, and encryption — work that's self-contained and produces a result you can ship back to the page.

Handling Errors and Terminating

If a worker throws an uncaught error, it surfaces on the main thread through worker.onerror:

worker.onerror = (event) => {
  console.error(`Worker error: ${event.message} (${event.filename}:${event.lineno})`);
};

A worker keeps running until it's stopped. You can stop it from the outside with worker.terminate(), which kills the thread immediately — any in-flight work is abandoned:

worker.terminate();

Or the worker can shut itself down from the inside once it's done:

// inside worker.js
self.close();

Terminating idle workers frees memory; long-lived workers that handle many messages are fine to keep around.

Transferable Objects: Move Instead of Copy

Structured cloning is convenient but it copies the data. For a large binary payload — say a 50 MB image buffer — copying both wastes memory and takes time. Transferable objects let you hand off ownership instead: the data is moved to the other thread with zero copy, and the sender loses access to it.

You opt in by passing a second argument to postMessage — a list of the objects to transfer:

const buffer = new ArrayBuffer(64 * 1024 * 1024); // 64 MB

// Transfer ownership of the buffer to the worker (no copy).
worker.postMessage({ buffer }, [buffer]);

console.log(buffer.byteLength); // 0 — this thread can no longer use it

After the transfer, buffer.byteLength is 0 on the sending side: the memory now belongs to the worker. Transferring is ideal for ArrayBuffers and the typed arrays built on them — see ArrayBuffer and binary arrays for how that binary data is structured. Other transferables include MessagePort, ImageBitmap, and OffscreenCanvas.

Note

The contents of the second argument must also appear in the message. In worker.postMessage({ buffer }, [buffer]), the buffer is referenced by the payload and listed as transferable. List something that isn't reachable from the message and the browser throws a DataCloneError.

Module Workers

By default a worker is a classic script, so it uses importScripts() rather than ES module syntax. Pass { type: 'module' } and the worker becomes a module worker that can use static and dynamic import:

const worker = new Worker('worker.js', { type: 'module' });

worker.js

import { compress } from './compression.js';

self.onmessage = (event) => {
  self.postMessage(compress(event.data));
};

Module workers are the modern default for new code — they get you proper imports, strict mode, and a cleaner dependency graph.

Other Kinds of Workers

A plain new Worker(...) creates a dedicated worker: it belongs to the single page that spawned it. There are two related — but distinct — worker types you should be able to tell apart:

  • SharedWorker — a single worker instance shared across multiple tabs, windows, or iframes from the same origin. Pages connect to it through a MessagePort, which makes it useful for coordinating state or a single network connection across tabs. It is not a faster dedicated worker; it's a shared one.
  • Service Worker — a special worker that acts as a network proxy, sitting between the page and the network to enable caching, offline support, and push notifications. It is event-driven and persists beyond the page's lifetime. That's a different job from a dedicated worker's "run this computation off-thread"; for that side of things, read Service Workers.
Info

Rule of thumb: reach for a dedicated Web Worker to move CPU-heavy work off the main thread, a SharedWorker to share one worker across tabs of the same site, and a Service Worker to control network requests and build offline-capable apps.

When to Use Web Workers

Web Workers earn their keep whenever a task is CPU-bound and long enough to be felt as jank:

  • Heavy computation — physics, data analysis, large sorts and aggregations.
  • Image and video processing, including pixel manipulation off-screen.
  • Parsing and compressing large files (CSV, JSON, archives).
  • Cryptography — hashing and encrypting without freezing input.
  • Crunching big datasets before handing a compact result back to render.

If the bottleneck is waiting on the network rather than computing, you usually don't need a worker — fetch is already asynchronous and non-blocking. Workers shine when the CPU itself is the thing keeping the main thread busy.

Test Your Knowledge

Practice
Can code running inside a Web Worker access the DOM directly?
Can code running inside a Web Worker access the DOM directly?
Practice
How does data passed to worker.postMessage(data) reach the worker by default?
How does data passed to worker.postMessage(data) reach the worker by default?
Practice
What is the main advantage of transferring an ArrayBuffer with worker.postMessage(buffer, [buffer])?
What is the main advantage of transferring an ArrayBuffer with worker.postMessage(buffer, [buffer])?
Was this page helpful?