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.
In this guide
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:
- 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.
- No shared variables. The two threads talk by messages. Data you send is copied rather than shared, with some exceptions covered below.
- 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 candidates | Poor candidates |
|---|---|
| Parsing and transforming large JSON or CSV data | Anything that reads or changes the page |
| Searching, filtering, sorting and grouping big lists | Very small jobs, where passing messages costs more than the work |
| Image, audio or video processing in memory | Work that must finish before the next frame is drawn |
| Compression and encryption | Code that uses interfaces missing from workers |
| Game logic, simulations and calculations | Code 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.
Making messages easier with Comlink
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 copiedSend 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
- Start-up. A worker is a new thread that has to download and run its script. Do not create one for a job that takes a few milliseconds. Create it once and reuse it.
- Messaging overhead. Copying, and waiting for the answer, add time. Measure the whole round trip, not only the work.
- Complexity. Asynchronous results, errors that arrive as events, and code split across two files all take care. Test the failure cases, such as a worker that cannot start.
- Missing interfaces. If a library you use needs the DOM, it will not run in a worker.
- Fallbacks. If you must support very old browsers, check for
Workerbefore using it.
How to check that it worked
- 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.
- Compare Total Blocking Time in the lab, and Interaction to Next Paint in the field over the following weeks.
- 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
- Use web workers to run JavaScript off the browser's main thread on web.dev: why workers help, Comlink and the worked example.
- Web Workers API, Using Web Workers, Transferable objects and the structured clone algorithm on MDN.
- Comlink from Google Chrome Labs: the README with size and browser support.
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