JavaScript is often called a single-threaded language. In practice that means the main thread is single-threaded: parsing JavaScript, rendering HTML, applying CSS, and handling most UI work all share it.

JavaScript only processes one operation at a time. If you want to do more than one heavy task, you wait for the current one to finish. When that work is expensive — two, five, or ten seconds — the page stops responding while the user tries to interact with it.

By default, almost everything runs on the main thread. With Web Workers, the browser lets you register extra threads. In that sense, Web Workers are how you get practical multithreading in JavaScript on the web.

Using Web Workers to improve page performance

Workers are ideal for computationally heavy work on a separate thread, so the main thread stays free for UI. If the main thread is busy and the user clicks or types, the page freezes — and that hurts Interaction to Next Paint (INP). Less main-thread work means interactions can be handled sooner.

Less main-thread work during startup can also help Largest Contentful Paint (LCP). Painting an LCP element needs main-thread time (text or images are common LCP candidates). Offloading expensive work to a worker makes that paint less likely to be blocked by long tasks.

A blocking example

Here is a simple example (full demos are embedded below). Typing in an input and clicking Search runs a function like this:

TypeScript
export const search = function wait(value) {
  const now = Date.now();
  while (Date.now() - now <= 2000) {
    // busy-wait ~2 seconds
  }
};

The page freezes for about two seconds before the result shows. Imagine this standing in for a heavy API response or CPU-bound work. While it runs, you cannot type or interact — a poor experience.

Moving work onto a worker

To run that search off the main thread, create a worker:

TypeScript
const worker = new Worker("worker.js");

The browser loads worker.js and starts a new worker thread. Move the heavy search logic into that file. On Search, send the input to the worker with postMessage():

TypeScript
button.addEventListener("click", () => {
  worker.postMessage(inputString);
  worker.onmessage = function (e) {
    resultText.innerHTML = e.data;
  };
});

Inside worker.js, receive the message, run the work, and reply:

TypeScript
onmessage = function (e) {
  const value = e.data;
  search(value);
  postMessage(`Searched for ${value}`);
};

That request/response pattern is the messaging pipeline between the main thread and the worker.

With the worker, clicking Search no longer freezes the UI. You can often get similar non-blocking behavior with async / await for I/O, but this example is a clear illustration of when a dedicated thread helps.

Real-world uses

  • Complex calculations (e.g. real-time analytics or physics)
  • CPU-heavy WebAssembly modules (games, simulations)
  • Background sync with a server (saving data, syncing files)
  • Real-time collaboration (shared document editing)

Limitations

Workers do not have direct access to the Window object, so they cannot touch the DOM. They send data to the main thread; the main thread updates the UI. That still goes through messaging.

They also do not share the full main-thread environment the way you might expect. Use the Web Workers API surface that is available on the worker.

Summing up

Web Workers move JavaScript work off the main thread so the UI can stay responsive. Multitasking this way can improve perceived load and interaction metrics, and may help scores in tools like Lighthouse. When a task is CPU-bound and blocks the UI, a worker is often the right tool.