Web workers: moving heavy work off the main thread

A web worker runs JavaScript on a separate thread so heavy jobs cannot freeze the page. What to move, how to do it, the limits, and when it is not worth it.

AdvancedHow-to6 min readUpdated October 30, 2026

In this guide
  1. What a worker is
  2. Why it helps
  3. What to move, and what to leave
  4. The basic pattern
  5. Making messages easier with Comlink
  6. Passing data: copies and transfers
  7. Costs and traps
  8. How to check that it worked
  9. Where to start
  10. Check your own page
  11. Sources

What a worker is

JavaScript is a single-threaded language. In a page, it runs tasks on the main thread, one at a time. A web worker is an escape hatch: you start a separate thread that runs its own file. MDN describes the advantage as letting laborious processing happen without blocking or slowing the main thread. web.dev notes that workers have existed since 2007 and have been supported in all major browsers since 2012.

A worker is not the same as a service worker, which sits between the page and the network, and not the same as a worklet. See service workers and speed. This guide is about the common kind, the dedicated worker, which only the script that created it can use.

Three rules shape everything you do with workers:

  1. No DOM. A worker cannot touch the page directly. web.dev adds that many other interfaces, such as WebUSB, WebRTC and Web Audio, are missing as well.
  2. No shared variables. The two threads talk by messages. Data you send is copied rather than shared, with some exceptions covered below.
  3. Messages are asynchronous. You send a request and receive the answer later.

Why it helps

The main thread is where the tap handlers, the style and layout work and the drawing all happen. When it is busy, the page does not respond. web.dev points out that moving work to a worker reduces contention for the main thread, which can help Interaction to Next Paint. Less work at startup also leaves the main thread free to draw the main content, which can help Largest Contentful Paint.

It is stronger than breaking up long tasks for one reason: the work really runs in parallel and does not have to take turns. It is weaker for another: it is more work to build and it has its own costs.

What to move, and what to leave

Good candidatesPoor candidates
Parsing and transforming large JSON or CSV dataAnything that reads or changes the page
Searching, filtering, sorting and grouping big listsVery small jobs, where passing messages costs more than the work
Image, audio or video processing in memoryWork that must finish before the next frame is drawn
Compression and encryptionCode that uses interfaces missing from workers
Game logic, simulations and calculationsCode that depends on a UI framework's components
State management that does not touch the DOM

web.dev's worked example, a Minesweeper-style game built by the Chrome team, separated the visual state from the game logic. The main thread handled animation and drawing, and a worker handled the purely computational game logic. That is a good picture of the principle: keep the page on the main thread, and put everything else somewhere else when it is heavy enough to matter.

The basic pattern

You create a worker by passing it a file. The two sides communicate with postMessage() and a message event.

// main.js
const worker = new Worker('./worker.js');
worker.postMessage([40, 2]);
worker.addEventListener('message', (event) => {
  console.log(event.data);   // 42
});
// worker.js
addEventListener('message', (event) => {
  const [a, b] = event.data;
  postMessage(a + b);
});

When you no longer need the worker, call worker.terminate() from the main thread to stop it at once.

If you use a bundler, MDN notes that webpack, Vite and Parcel recommend writing the path relative to the current script, as new Worker(new URL('./worker.js', import.meta.url)). That way the bundler can see the file and handle it correctly. A worker can also be a module, with { type: 'module' } as the second argument, which works in Chrome 80, Firefox 114 and Safari 15. Basic workers have worked since Chrome 2, Firefox 3.5, Safari 4 and Edge 12.

Plain messages become awkward as soon as a worker does more than one thing. You have to describe the operation in each message and match answers to requests. Comlink is a small library from the Chrome team that hides this. It is 1.1 KB compressed with Brotli, according to its documentation. The worker exposes an object, and the main thread wraps the worker and calls the object's functions as if they were local:

// worker.js
import { expose } from 'comlink';
expose({ search(text) { /* heavy work */ return results; } });

// main.js
import { wrap } from 'comlink';
const api = wrap(new Worker('./worker.js'));
const results = await api.search('shoes');

Every call returns a promise, because the work happens elsewhere. Comlink relies on a browser feature called proxies, which the documentation lists as supported from Chrome 56, Edge 15, Firefox 52 and Safari 10.1.

Passing data: copies and transfers

When you send an object, the browser copies it with the structured clone algorithm. That is fast for small data and slow for large data, because it has to walk through the whole object. Some things cannot be copied. MDN lists functions and DOM nodes, which throw an error if you try. Class instances lose their prototype, and getters and setters are not preserved.

For large binary data there is a faster way. Some objects, such as an ArrayBuffer, can be transferred. MDN explains that the memory is moved to the other thread without copying, in a fast, zero-copy operation. The sending side can no longer use it after the transfer. You name the objects to transfer in the second argument:

const buffer = new Uint8Array(8 * 1024 * 1024).buffer;
worker.postMessage({ buffer }, [buffer]);   // moved, not copied

Send as little as possible, and not often. Many small messages, and large objects that are copied each time, can cost more than the work they save.

Costs and traps

How to check that it worked

  1. Record the interaction in the Performance panel before and after. The worker's work shows on its own track, and the main thread should show shorter tasks.
  2. Compare Total Blocking Time in the lab, and Interaction to Next Paint in the field over the following weeks.
  3. Test on a slow phone or with the processor throttled, because that is where a worker helps most.

Where to start

If the job needs the DOM, or is only moderately long, break it into pieces first. If it is heavy, does not need the page, and happens often, use a worker. If the heavy code belongs to someone else, such as a third-party tag, you usually cannot move it, and the answer is to remove or delay it. See third-party scripts.

Check your own page

Run the free audit and open Avoid long main-thread tasks. If the longest tasks come from your own code and the work does not touch the page, they are good candidates for a worker. If they come from other companies' scripts, a worker is not the tool.

Sources

Start here

Free website audit

Live Google data for any public page: mobile and desktop.

Mobile + desktop · Lighthouse lab test · Real-user Chrome data when Google has it

Rather have this done for you? Speed optimization · Book a call

Related guides

All guides →