The browser runs JavaScript, style calculation, layout and painting on a single main thread. As long as the code is light there is no problem, but when a page has to process a CSV file with thousands of rows, filter a large list on every keystroke, compress an image or run a search algorithm, the main thread locks up and the interface stops responding.
Breaking the work into small chunks helps, but it does not reduce the total amount of computation. Web workers offer a different route: they run JavaScript on a separate thread, leaving the main thread free to respond to the user.
What a worker can and cannot do
A worker is an isolated JavaScript environment. It has access to many useful APIs, but not all of them:
| Available in a worker | Not available in a worker |
|---|---|
fetch, WebSocket |
DOM access |
| IndexedDB, Cache Storage | window and document |
| Timers, Promises, ES modules | localStorage |
| Cryptography (Web Crypto) | Direct page manipulation |
OffscreenCanvas |
UI events |
The practical rule: the worker computes, the main thread displays. Everything about how the page looks stays on the main thread; everything that is pure data processing can move.
A minimal example
Creating a worker requires a separate file. With ES modules the syntax is modern and clean:
// main.js
const worker = new Worker(new URL('./filter.worker.js', import.meta.url),
{ type: 'module' });
input.addEventListener('input', () => {
worker.postMessage({ text: input.value });
});
worker.addEventListener('message', (e) => {
showResults(e.data.results);
});
// filter.worker.js
let catalog = [];
fetch('/data/catalog.json').then(r => r.json()).then(d => { catalog = d; });
self.addEventListener('message', (e) => {
const q = e.data.text.toLowerCase();
const results = catalog.filter(p => p.name.toLowerCase().includes(q))
.slice(0, 50);
self.postMessage({ results });
});
The user types, the main thread sends the text and stays free; the worker filters and returns only the first results to render. Even with a large catalog, typing does not stall.
The cost of communication
Messages between the main thread and the worker are copied using the structured clone algorithm. For small objects the cost is negligible, but copying large structures on every message can cancel out the benefit. There are three strategies to limit it:
- keep the data in the worker: as in the example, the catalog is loaded once inside the worker and only the query and a reduced result travel;
- transferable objects: an
ArrayBuffercan be transferred instead of copied, handing ownership to the worker in almost no time. After the transfer it can no longer be used by the sender; - send less: return only what is needed to render, not the whole processed set.
const buffer = await file.arrayBuffer();
worker.postMessage({ buffer }, [buffer]); // transferred, not copied
Jobs suited to a worker
In my work, the cases where a worker really makes a difference are always of this kind:
- parsing files uploaded by the user: CSV, large JSON, logs;
- searching and sorting long client-side lists;
- image processing: resizing, filters, format conversion via
OffscreenCanvas; - compressing and decompressing data;
- syntax highlighting or formatting of long texts;
- numerical computation: statistics, simulations, charts with many points.
It is not worth moving short operations to a worker: creating the worker and exchanging messages has a cost, and for jobs of a few milliseconds the gain is zero.
Making communication more convenient
Handling messages and events by hand gets awkward when there are many functions. Libraries such as Comlink turn the worker into an object with asynchronous methods: from the main thread you call await api.filter(text) as if it were a local function, and the library handles the messages. It is a convenience, not a requirement: for one or two message types, hand-written code stays clear.
Common mistakes
- Forgetting error handling: an exception in the worker does not show up in the main thread's flow. Listen for the
errorevent and, in messages, return an explicit status. - Creating a worker for every operation: create it once and reuse it.
- Out-of-order responses: if the user types quickly, an old response may arrive after a new one. Attach an identifier to each request and ignore stale responses.
- Expecting DOM access: if the code touches
document, split it into a computation part and a presentation part.
Verifying the benefit
In the DevTools Performance panel, workers appear as separate tracks. Before the change you will see long tasks on the main thread during the operation; afterwards the work moves to the worker's track and the main thread shows only short tasks for receiving and painting. Repeat the measurement with CPU throttling to simulate a low-end device.
In short
- A web worker runs JavaScript on a separate thread and frees the main thread.
- The worker processes data; the DOM and the interface stay on the main thread.
- Keep large data in the worker and use transferable objects for buffers.
- It helps with file parsing, searching, images and computation, not with short operations.
- Handle errors and out-of-order responses, and check the result in a profile.