Complete JavaScript Course Module 15: Advanced: Browser APIs and Performance

Web Workers in JavaScript: Run Code in the Background

  • Beginner
  • Lesson 15.3
  • 7 min read
  • 69 of 76 in the course

In short

Web Workers let JavaScript run heavy code in the background so your page never freezes. Learn postMessage, onmessage, module workers, error handling and terminate. Why does a slow function freeze the page? The main thread JavaScript in the browser runs on one main thread.

Web Workers let JavaScript run heavy code in the background so your page never freezes. Learn postMessage, onmessage, module workers, error handling and terminate.

Why does a slow function freeze the page? The main thread

JavaScript in the browser runs on one main thread. A thread is like a single worker in a small shop who must do every job: take orders, clean tables and answer the phone. If that worker spends five minutes counting coins, nobody else gets served.

The main thread runs your code and handles clicks, scrolling, typing and painting the screen. When a function takes a long time, the whole page stops responding. Try this demo:

<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><title>Blocking demo</title></head>
<body>
  <button id="count">Clicked 0 times</button>
  <button id="heavy">Run heavy task</button>
  <p id="result"></p>

  <script>
    let clicks = 0;
    document.getElementById("count").addEventListener("click", (e) => {
      clicks++;
      e.target.textContent = `Clicked ${clicks} times`;
    });

    document.getElementById("heavy").addEventListener("click", () => {
      let total = 0;
      for (let i = 0; i < 2_000_000_000; i++) { total += i; } // takes a few seconds
      document.getElementById("result").textContent = `Done: ${total}`;
    });
  </script>
</body>
</html>

Click "Run heavy task" and then quickly click the counter. Nothing happens for a few seconds. The page is blocked. When the loop finally ends, your clicks are handled all at once.

Async code with await does not fix this. await helps when you are waiting for something, like a network response. It does not help when JavaScript itself is busy calculating. For that you need a second thread: a Web Worker.

How to create a Web Worker: postMessage and onmessage

A Web Worker is a JavaScript file that runs on its own background thread. It cannot touch the page (no document, no DOM), but it can calculate, use fetch, and use timers. The page and the worker talk by sending messages, like two people passing notes.

  • worker.postMessage(data) sends a note to the worker.
  • Inside the worker, self.onmessage receives it and self.postMessage(result) sends an answer back.
  • On the page, worker.onmessage receives the answer. The data is in event.data.

Create two files in the same folder. First, the worker:

// worker.js
self.onmessage = (event) => {
  const limit = event.data;          // the number the page sent us
  let total = 0;
  for (let i = 0; i < limit; i++) { total += i; }
  self.postMessage(total);           // send the answer back
};

Then the page:

<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><title>Worker demo</title></head>
<body>
  <button id="count">Clicked 0 times</button>
  <button id="heavy">Run heavy task</button>
  <p id="result"></p>

  <script>
    let clicks = 0;
    document.getElementById("count").addEventListener("click", (e) => {
      clicks++;
      e.target.textContent = `Clicked ${clicks} times`;
    });

    const worker = new Worker("worker.js");          // start the background thread
    const result = document.getElementById("result");

    worker.onmessage = (event) => {                   // the answer arrives here
      result.textContent = `Done: ${event.data}`;
    };

    document.getElementById("heavy").addEventListener("click", () => {
      result.textContent = "Working...";
      worker.postMessage(2_000_000_000);              // send the job
    });
  </script>
</body>
</html>

Now the counter keeps working while the heavy task runs. After a few seconds you see "Done:" followed by a very large number. The calculation happens on the worker's thread, so the main thread stays free.

Note: Workers do not load from a file you double-click (file://). Serve the folder with a local server, for example the VS Code Live Server extension or npx vite, and open it at http://localhost.

Sending and transferring data to a worker

postMessage can send almost any normal data: numbers, strings, arrays, plain objects, Map, Set, Date and more. The data is copied with the same rules as structuredClone(), so the worker gets its own copy. Functions and DOM elements cannot be sent.

Copying a huge amount of data, like a 50 MB image, takes time. For binary data such as an ArrayBuffer, you can transfer it instead. Transferring is like handing over a box instead of photocopying everything inside: it is instant, but you no longer have the box.

// On the page (with the worker from above)
const pixels = new Uint8Array(50_000_000);   // 50 MB of data
console.log(pixels.buffer.byteLength);       // Output: 50000000

worker.postMessage(pixels.buffer, [pixels.buffer]); // second argument = list to transfer

console.log(pixels.buffer.byteLength);       // Output: 0 (it now belongs to the worker)

You can send objects with a "type" field so one worker can do several jobs:

// tasks-worker.js
self.onmessage = ({ data }) => {
  if (data.type === "sum") {
    self.postMessage({ type: "sum", result: data.numbers.reduce((a, b) => a + b, 0) });
  } else if (data.type === "sort") {
    self.postMessage({ type: "sort", result: data.numbers.toSorted((a, b) => a - b) });
  }
};

// On the page
const tasks = new Worker("tasks-worker.js");
tasks.onmessage = ({ data }) => console.log(data);
tasks.postMessage({ type: "sum", numbers: [4, 8, 15] });
// Output: { type: "sum", result: 27 }
tasks.postMessage({ type: "sort", numbers: [9, 2, 5] });
// Output: { type: "sort", result: [2, 5, 9] }

Module workers, errors and terminate

By default, a worker is a classic script. If you add { type: "module" }, it becomes a module worker, and you can use import and export inside it, just like in a normal module. All modern browsers support this.

// math.js
export const average = (nums) => nums.reduce((a, b) => a + b, 0) / nums.length;

// stats-worker.js
import { average } from "./math.js";   // imports work in module workers
self.onmessage = ({ data }) => self.postMessage(average(data));

// main.js (loaded with <script type="module" src="main.js">)
const worker = new Worker(new URL("./stats-worker.js", import.meta.url), { type: "module" });
worker.onmessage = ({ data }) => console.log("Average:", data);
worker.postMessage([70, 85, 90]);
// Output: Average: 81.66666666666667

new URL("./stats-worker.js", import.meta.url) builds the worker's address relative to the current file. Build tools like Vite understand this pattern and bundle the worker for you.

If code inside the worker throws an error, the page gets an error event. Always listen for it, or the failure is easy to miss:

worker.onerror = (event) => {
  console.log("Worker error:", event.message, "at line", event.lineno);
  event.preventDefault(); // stop the browser from also logging it
};

When you no longer need a worker, stop it. worker.terminate() stops it immediately from the page, and self.close() lets the worker stop itself. A stopped worker cannot be restarted; create a new one if you need it again.

Method / eventWhereWhat it does
new Worker(url, options)PageStarts a worker; { type: "module" } allows imports
postMessage(data, transfer)BothSends a message (copies data, or transfers listed buffers)
onmessageBothReceives messages; data is in event.data
onerrorPageRuns when the worker throws an error
terminate()PageStops the worker right away
self.close()WorkerThe worker stops itself

Common mistake: Trying to use document.querySelector or alert inside a worker. Workers have no DOM. Send the result back to the page with postMessage and update the page there.

Real uses for Web Workers

Workers are worth it when a task takes more than about 50 milliseconds of pure calculation. Real examples:

  • Image and video processing: filters, resizing or compressing a photo before upload.
  • Parsing or searching big files, like a large CSV or JSON export, in the browser.
  • Heavy maths: charts with millions of points, physics in games, encryption.
  • Spell checking or syntax highlighting in an online code editor.
  • Running WebAssembly libraries, such as PDF or video tools, without freezing the page.

For small jobs, a worker adds extra code and a little startup time for nothing. Measure first: if the page does not freeze, you probably do not need one.

Practice exercise

Build a "prime number finder". The page has a number input, a "Find primes" button and a counter button. When you click "Find primes", send the number to a worker, which counts how many prime numbers exist below it and sends the count back. Show "Working..." while it runs, and check that the counter button still works with a big input like 10,000,000.

Hint: in the worker, loop from 2 to the limit and test each number by checking divisors up to Math.sqrt(n). Add an onerror handler on the page too.

Key takeaways

  • The main thread runs your code and the page, so long calculations freeze clicks and scrolling.
  • A Web Worker runs a separate file on a background thread and has no access to the DOM.
  • The page and worker talk with postMessage and onmessage; data is copied, and an ArrayBuffer can be transferred.
  • Use { type: "module" } to import inside a worker, listen for onerror, and terminate() workers you no longer need.
  • Use workers for heavy calculation, not for waiting; async/await already handles waiting.