Mert Özen Further With Every Line
Frontend JavaScript Advanced — #11

JavaScript Web Workers: postMessage, Transferable Objects and Module Workers

Mert Özen Nov 3, 2026 10 min 6 views
JavaScript Web Workers: postMessage, Transferable Objects and Module Workers

We move heavy work off the main thread with Web Workers and learn postMessage and structured clone, single-file Blob URL workers, transferable objects, module workers and life without the DOM.

Version note: The code in this post targets ES2026 and current browsers (Chrome, Edge, Firefox, Safari).

Predict First

Before explaining anything, let's set up a small worker and send it an object. What does this code print to the console, and in what order?

const code = `onmessage = (e) => postMessage(e.data.number * 2);`;
const blob = new Blob([code], { type: "text/javascript" });
const worker = new Worker(URL.createObjectURL(blob));

worker.onmessage = (e) => console.log("from worker:", e.data);

const data = { number: 21 };
worker.postMessage(data);
data.number = 100;
console.log("main thread:", data.number);

Make a guess before you keep reading. Watch out for two things: which line is printed first, and does the worker see 21 or 100? If you want to try the code, paste it into the console of an empty tab (about:blank); some sites block this kind of worker with their security rules.

Why Workers? The Limit of a Single Thread

We saw it when covering the event loop: a page's JavaScript runs on a single thread, the main thread. That same thread also handles style calculation, layout, painting and the user's clicks. In the previous post we said that some work is heavy no matter how well you plan it. When a calculation that takes two seconds starts, for those two seconds buttons don't respond, animations stop and the page looks frozen.

Think of a restaurant kitchen. The chef stands at the pass, takes orders and sends out plates. Ask them to also chop a hundred kilos of onions and service grinds to a halt. The smart move is to give the heavy prep to an assistant in the back kitchen. The assistant doesn't go out to the dining room or talk to customers; they only pass notes back and forth with the chef through the service window. A Web Worker is exactly that assistant: a separate JavaScript environment running on its own thread with its own event loop. It shares no variables with the main thread, it can't touch the DOM, and all communication happens through messages.

A First Worker: Two Files and postMessage

In the classic setup, the worker's code lives in a separate file. Put two files in the same folder. First, worker.js:

self.onmessage = (e) => {
  const { start, end } = e.data;
  let total = 0;
  for (let i = start; i <= end; i++) total += i;
  self.postMessage(total);
};

Then index.html:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>First worker</title>
</head>
<body>
  <script>
    const worker = new Worker("worker.js");

    worker.onmessage = (e) => console.log("Total:", e.data);
    worker.onerror = (e) => console.error("Worker error:", e.message);

    worker.postMessage({ start: 1, end: 1_000_000 });
  </script>
</body>
</html>

If you open this page by double-clicking the file (file://), browsers like Chrome refuse to load the worker file with a SecurityError; you need to open the folder over http with any simple local static server, such as your editor's live preview feature or python3 -m http.server.

Both sides use the same two tools: they send with postMessage and receive with the message event. The incoming data is always in e.data. Inside the worker, self is the counterpart of window on the page; it's the worker's own global object. The address passed to new Worker is resolved relative to the page's address and has to come from the same origin.

A Worker in a Single File: Blob URLs

For small experiments, two files and a server can feel like overkill. If you put the worker's code as a string into a Blob and create a blob: URL from it, a single HTML file is enough, and you can open it with a double-click. A blob URL carries the origin of the page that created it, so it doesn't run into the same-origin rule. The page below also shows the difference: we run the same heavy calculation once on the main thread and once in a worker, while a counter ticks forward on every frame.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>A worker in a single file</title>
</head>
<body>
  <p>Frame counter: <span id="frame">0</span></p>
  <button id="main">Calculate on the main thread</button>
  <button id="background">Calculate in a worker</button>
  <p id="result"></p>

  <script>
    function countPrimes(limit) {
      let count = 0;
      for (let n = 2; n <= limit; n++) {
        let prime = true;
        for (let d = 2; d * d <= n; d++) {
          if (n % d === 0) { prime = false; break; }
        }
        if (prime) count++;
      }
      return count;
    }

    const code = `
      ${countPrimes.toString()}
      self.onmessage = (e) => self.postMessage(countPrimes(e.data));
    `;
    const url = URL.createObjectURL(new Blob([code], { type: "text/javascript" }));
    const worker = new Worker(url);
    const result = document.querySelector("#result");
    const LIMIT = 10_000_000;

    let frame = 0;
    (function loop() {
      document.querySelector("#frame").textContent = ++frame;
      requestAnimationFrame(loop);
    })();

    document.querySelector("#main").addEventListener("click", () => {
      result.textContent = "Calculating...";
      setTimeout(() => {
        const t0 = performance.now();
        const count = countPrimes(LIMIT);
        result.textContent = `Main thread: ${count} primes, ${(performance.now() - t0).toFixed(0)} ms`;
      }, 50);
    });

    document.querySelector("#background").addEventListener("click", () => {
      result.textContent = "Calculating...";
      const t0 = performance.now();
      worker.onmessage = (e) => {
        result.textContent = `Worker: ${e.data} primes, ${(performance.now() - t0).toFixed(0)} ms`;
      };
      worker.postMessage(LIMIT);
    });
  </script>
</body>
</html>

When you press "Calculate on the main thread", the frame counter freezes; the page is dead until the calculation finishes. With "Calculate in a worker", the counter keeps ticking and the result shows up once it's ready. The time is about the same; a worker doesn't make the work faster, it just gets it out of the main thread's way. We didn't write the worker's code separately: countPrimes.toString() returns the function's source code as a string, and we dropped it into the worker code. That way the same function runs on both sides.

postMessage and Structured Clone

Now back to the opening guess. main thread: 100 is printed first, then from worker: 42. The message travels asynchronously, so the lines on the main thread finish first. The reason the worker sees 21 is this: postMessage doesn't share the object, it copies it. The copy is taken at the moment postMessage is called; changing data.number afterwards doesn't affect the worker's copy.

The copying uses the same algorithm as the structuredClone we saw earlier. You can try the same rules directly in the console with structuredClone:

class Point {
  constructor(x) { this.x = x; }
  length() { return Math.abs(this.x); }
}

const copy = structuredClone({
  date: new Date(0),
  tags: new Set(["a"]),
  point: new Point(3)
});

console.log(copy.date instanceof Date);   // true
console.log(copy.tags.has("a"));          // true
console.log(copy.point instanceof Point); // false, a plain object: { x: 3 }

try {
  structuredClone({ compute: () => 1 });
} catch (e) {
  console.log(e.name); // DataCloneError
}

Primitives, plain objects, arrays, Date, Map, Set, RegExp, ArrayBuffer, typed arrays and Blob all go through fine. Class instances lose their prototype and turn into plain objects. Functions and DOM nodes can't be sent at all; postMessage throws a DataCloneError right away.

There are two ways to shut down a worker that's done: from outside, worker.terminate() stops it immediately; from inside, self.close() lets the worker close itself. An uncaught error in a worker reaches the main thread as an error event; if you don't attach onerror, you'll only see it in the console.

Transferable Objects: Hand It Over Instead of Copying

Copying is fine for small messages. But copying 64 MB of image data costs both time and memory. That's what transferable objects are for: instead of copying some objects, you can hand over their ownership. The second argument of postMessage is a transfer list:

const helper = new Worker(URL.createObjectURL(new Blob([`
  onmessage = (e) => {
    const bytes = new Uint8Array(e.data);
    postMessage("Worker received " + bytes.length + " bytes");
  };
`], { type: "text/javascript" })));

helper.onmessage = (e) => console.log(e.data);

const buffer = new ArrayBuffer(64 * 1024 * 1024);
helper.postMessage(buffer, [buffer]);
console.log("Size on the main thread:", buffer.byteLength); // 0

A transfer is nearly instant because the data isn't copied; the block of memory moves over to the worker as it is. The price: the buffer on the main thread is now "detached", an empty shell. Its byteLength becomes 0, and if you try to build a typed array on top of it you get a TypeError. Once you hand someone the key, you can't open the door yourself anymore. When the worker is done, it can send the buffer back the same way.

One thing to watch: typed arrays like Uint8Array can't be transferred themselves, but their underlying buffer can; so you write postMessage(array, [array.buffer]). Besides ArrayBuffer, MessagePort, ImageBitmap and OffscreenCanvas can be transferred too. You can also use the same transfer list with structuredClone(value, { transfer: [...] }). For truly shared memory there's also SharedArrayBuffer, but it requires the page to be "cross-origin isolated" through special headers from the server, so it's outside the scope of this post.

Module Workers: { type: "module" }

In a classic worker, you load another file with importScripts("helper.js"). The more modern way is to start the worker as a module: new Worker("worker.js", { type: "module" }). Then the import and export we saw earlier work inside the worker. Module workers have been available since Chrome 80, Firefox 114 and Safari 15, and have been Baseline widely available since December 2025. Here's a small three-file example, again meant to be opened from a local server. math.js:

export function average(numbers) {
  return numbers.reduce((a, b) => a + b, 0) / numbers.length;
}

worker.js:

import { average } from "./math.js";

self.onmessage = (e) => {
  self.postMessage(average(e.data));
};

index.html:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Module worker</title>
</head>
<body>
  <script>
    const worker = new Worker("worker.js", { type: "module" });
    worker.onmessage = (e) => console.log("Average:", e.data);
    worker.postMessage([3, 5, 10]);
  </script>
</body>
</html>

Like module scripts, module workers automatically run in strict mode, and importScripts isn't available inside them. They load their dependencies before the code starts running. For new code, you can treat module workers as the default choice.

A Worker's World: What's There and What Isn't

A worker lives in a separate world. The easiest way to see what's available is to ask the worker. Try this in the console:

const probe = new Worker(URL.createObjectURL(new Blob([`
  postMessage({
    document: typeof document,
    window: typeof window,
    localStorage: typeof localStorage,
    fetch: typeof fetch,
    setTimeout: typeof setTimeout,
    indexedDB: typeof indexedDB
  });
`], { type: "text/javascript" })));

probe.onmessage = (e) => console.table(e.data);

You'll see undefined for document, window and localStorage. There's no DOM in a worker; if you want to change an element's color or read an input, you send the result to the main thread and let it update the DOM. UI functions like alert aren't there either. On the other hand, fetch, timers, promises, WebSocket, IndexedDB, crypto and performance are all available. We'll touch on OffscreenCanvas, which lets you draw on a canvas in the background, in the canvas post.

The kind we covered in this post is called a dedicated worker, a worker that belongs to a single page. You should know its two relatives as well. A SharedWorker is a single worker shared by several tabs or iframes on the same origin; desktop browsers have had it for a long time, and it became Baseline in May 2026 once it reached Chrome for Android. A Service Worker plays a completely different role: it works like a proxy sitting between the page and the network, can intercept requests and answer them from a cache, and makes a site work offline. With its own lifecycle, it's a separate and broad topic.

Common Pitfalls

Creating a new worker for every small job. Starting a worker has a cost too: a new JavaScript environment is set up and the code is loaded. Create it once, reuse it again and again, and terminate() it once the work is completely done.

Messages that are too small and too frequent. Every message is copied and queued as an event. Instead of sending thousands of tiny messages per second, batch the work and send fewer, larger messages.

Drowning in callbacks. Working with onmessage feels a lot like the callback world we saw earlier. For one-off requests, a small promise wrapper makes the code much more comfortable:

function runInWorker(worker, data) {
  return new Promise((resolve, reject) => {
    worker.addEventListener("message", (e) => resolve(e.data), { once: true });
    worker.addEventListener("error", reject, { once: true });
    worker.postMessage(data);
  });
}

// Usage: const result = await runInWorker(worker, 5_000_000);

This version is safe for one request at a time. If you send several jobs to the same worker at once, you need to put an ID on each message and match the reply by that ID.

Continuing to use something you transferred. If you put an ArrayBuffer in the transfer list and later try to read it on the main thread, you'll find an empty shell. There's also the MIME type issue: if the server doesn't send the worker file as text/javascript, the browser refuses to run it.

A Small Experiment

★☆☆ Predict the output: you send worker.postMessage({ number: 5, tags: new Map([["a", 1]]) }) to the opening worker, followed by worker.postMessage({ number: 5, compute() {} }). Which one reaches the worker, which one fails, and on which thread is the error thrown?

★★☆ Change the single-file prime example so that the worker sends { type: "progress", percent } at every ten percent of progress and { type: "result", count } at the end, and show the progress in a <progress> element.

★★★ Split the prime counting across navigator.hardwareConcurrency workers, with each worker counting one range. Combine the results with runInWorker and Promise.all, and measure with performance.now() how much faster it is compared to a single worker.

If one way to take load off the main thread is handing work to another thread, the other way is not doing the work at all. Instead of constantly asking "is this element on screen, has its size changed?" while scrolling, you can let the browser tell you. In the next post we'll do exactly that with IntersectionObserver, MutationObserver and ResizeObserver.