JavaScript Web Workers: Run CPU-Heavy Code in the Background
Web Workers let you move expensive JavaScript work off the main browser thread so your page stays responsive while calculations, parsing, or data processing run in the background. This article explains what Web Workers are, how they communicate with the page, and when they are the right tool for the job.
Quick answer: A Web Worker runs JavaScript in a separate thread from the page’s main thread. You use it for CPU-heavy tasks, and you communicate with it by sending messages with postMessage instead of calling its functions directly.
Difficulty: Intermediate
You'll understand this better if you know: basic JavaScript functions, objects, and how browser pages respond to user input.
1. What Is Web Workers?
Web Workers are a browser API that runs JavaScript in the background, separate from the page’s main thread. They are designed for work that would otherwise freeze the UI, such as large calculations, file parsing, image processing, or transforming big datasets.
- They run in a different thread from the page.
- They cannot access the DOM directly.
- They communicate with the page through messages.
- They are useful for CPU-heavy tasks, not for simple event handling.
A worker is not a general replacement for normal JavaScript. It is a specialized tool for keeping the main thread free so scrolling, typing, clicks, and animations stay smooth.
2. Why Web Workers Matters
The browser’s main thread handles rendering, input events, and most of your page’s JavaScript. If you run a long loop or heavy computation there, the browser cannot update the screen or respond quickly.
Web Workers matter because they let you separate slow work from interactive work. That improves user experience in apps that process large data, run simulations, analyze media, or do client-side encryption and compression.
Use a worker when the task is heavy enough to cause visible lag. Do not use a worker just because code can be moved there; worker setup and message passing add overhead.
3. Basic Syntax or Core Idea
The core pattern is simple: create a worker, send it data, and listen for results.
Create a worker from a separate file
In the main page script, you create a worker with a file URL. Then you send it a message containing the work you want done.
const worker = new Worker("worker.js");
worker.postMessage({ numbers: [10, 20, 30] });
worker.addEventListener("message", (event) => {
console.log("Result from worker:", event.data);
});The worker file listens for the message, performs the work, and sends a result back.
self.addEventListener("message", (event) => {
const numbers = event.data.numbers;
const sum = numbers.reduce((total, value) => total + value, 0);
self.postMessage({ sum });
});This pattern keeps the page responsive while the worker computes the result.
Use an inline callback for simple replies
You can also assign the onmessage handler directly when you only need one message stream.
const worker = new Worker("worker.js");
worker.onmessage = (event) => {
console.log(event.data);
};This is functionally similar to addEventListener("message", ...) and is fine for small examples.
4. Step-by-Step Examples
Example 1: Offload a large sum calculation
Suppose you need to add millions of numbers. Doing that in the page script can pause the UI. In a worker, the calculation runs separately.
// main.js
const worker = new Worker("sum-worker.js");
worker.onmessage = (event) => {
console.log("Sum:", event.data.sum);
};
const numbers = Array.from({ length: 1_000_000 }, (_, index) => index + 1);
worker.postMessage({ numbers });
// sum-worker.js
self.onmessage = (event) => {
const sum = event.data.numbers.reduce((total, value) => total + value, 0);
self.postMessage({ sum });
};The main thread prepares the input and displays the result, while the worker handles the expensive reduction.
Example 2: Parse a large JSON-like payload
If your app receives a very large text payload, parsing and transforming it in the main thread can cause a noticeable pause. A worker can do the parsing and send back only the needed summary.
// main.js
const worker = new Worker("parser-worker.js");
worker.onmessage = (event) => {
console.log("Parsed count:", event.data.count);
};
worker.postMessage({
text: "Alice,30\nBob,25\nCarol,41"
});
// parser-worker.js
self.onmessage = (event) => {
const rows = event.data.text.split("\n");
self.postMessage({ count: rows.length });
};Notice that the worker returns only the information the page needs, which keeps message payloads smaller.
Example 3: Use transferable objects for large binary data
For large typed arrays or binary buffers, transferring ownership can be much faster than copying the whole object.
// main.js
const worker = new Worker("buffer-worker.js");
const buffer = new ArrayBuffer(1024);
worker.postMessage({ buffer }, [buffer]);
// buffer-worker.js
self.onmessage = (event) => {
const buffer = event.data.buffer;
const view = new Uint8Array(buffer);
view[0] = 255;
self.postMessage({ firstByte: view[0] });
};Passing buffer in the transfer list moves ownership instead of copying bytes, which is ideal for large binary payloads.
Example 4: Handle worker errors
Workers can fail if the script cannot load or if an exception is thrown inside the worker. You should listen for error events so failures do not disappear silently.
const worker = new Worker("task-worker.js");
worker.onmessage = (event) => {
console.log("Done:", event.data);
};
worker.onerror = (event) => {
console.error("Worker failed:", event.message);
};This pattern helps you diagnose bad paths, syntax errors in worker files, and runtime exceptions.
5. Practical Use Cases
- Crunching large arrays for analytics dashboards without freezing the UI.
- Parsing CSV, XML, or large text files after a user uploads them.
- Running simulation or math-heavy algorithms such as pathfinding or scoring.
- Processing images or canvas pixel data in a background thread.
- Compressing, hashing, or encrypting data before upload.
- Splitting large data transformations into independent background jobs.
Workers are especially valuable when the user should still be able to type, scroll, open menus, or cancel an operation while the task runs.
6. Common Mistakes
Mistake 1: Trying to access the DOM inside a worker
Workers do not share the page’s document object. That means code that manipulates elements, reads layout, or uses browser UI nodes will not work inside the worker file.
Problem: The worker environment does not provide document, so this code fails because workers cannot directly touch the DOM.
// worker.js
self.onmessage = () => {
const title = document.querySelector("h1").textContent;
self.postMessage({ title });
};Fix: Read DOM data in the main thread, then send only the needed values to the worker.
// main.js
const title = document.querySelector("h1").textContent;
const worker = new Worker("worker.js");
worker.postMessage({ title });
// worker.js
self.onmessage = (event) => {
self.postMessage({ titleLength: event.data.title.length });
};The corrected version works because the worker receives plain data instead of trying to query the page.
Mistake 2: Sending non-cloneable values with postMessage
Worker communication uses the structured clone algorithm. Functions, DOM nodes, and some special objects cannot be cloned and will trigger a DataCloneError.
Problem: This message includes a function, which cannot be copied to the worker.
const worker = new Worker("worker.js");
worker.postMessage({
count: 3,
format: () => "done"
});Fix: Send only data, then recreate behavior in the worker with normal functions.
const worker = new Worker("worker.js");
worker.postMessage({
count: 3,
formatName: "done"
});
// worker.js
self.onmessage = (event) => {
const message = event.data.formatName.toUpperCase();
self.postMessage({ message });
};The fixed version works because messages contain plain serializable data.
Mistake 3: Assuming worker scripts can be loaded from any path
A worker file must be reachable from the page and served with the correct origin rules. If the path is wrong or the server blocks it, the worker fails to start.
Problem: If the browser cannot fetch the worker script, you may see an error such as Failed to construct 'Worker' or a network failure in DevTools.
// main.js
const worker = new Worker("/scripts/missing-worker.js");
worker.onmessage = (event) => console.log(event.data);Fix: Verify the file path, serve the app over HTTP, and keep the worker URL relative to a real file in your build output.
// main.js
const worker = new Worker("./workers/missing-worker.js");
worker.onmessage => { /* handle result */ };The corrected version works when the file exists at the specified path and the browser can load it.
7. Best Practices
Practice 1: Send small messages, not huge objects
Workers are fastest when they exchange focused data. Sending very large nested objects increases cloning cost and can offset the benefit of background execution.
// Better: send only what the worker needs
worker.postMessage({
rows: rows.slice(0, 5000)
});Smaller messages reduce transfer overhead and make worker communication easier to reason about.
Practice 2: Use transferable objects for binary data
If you work with large ArrayBuffer or typed array data, transfer ownership instead of cloning it. This avoids copying bytes and improves throughput.
const buffer = new ArrayBuffer(4096);
worker.postMessage({ buffer }, [buffer]);Transfer lists are the right choice when the main thread no longer needs the original buffer.
Practice 3: Terminate workers you no longer need
Workers keep running until you stop them. If a worker is temporary, terminate it after the job completes to avoid wasted memory and CPU time.
worker.onmessage => {
console.log("Finished");
worker.terminate();
};Cleaning up workers helps prevent background tasks from lingering after the UI no longer needs them.
8. Limitations and Edge Cases
- Workers cannot manipulate the DOM directly, so all UI changes must happen on the main thread.
- Worker communication copies most data unless you use transferable objects, which adds overhead for small tasks.
- Workers are not ideal for short tasks; setup time can outweigh the benefit.
- Local file restrictions and server configuration can block worker loading during development.
- Module workers and classic workers have different loading behavior, so bundler and browser support can affect your setup.
- Debugging can feel less direct because errors happen in a separate execution context.
A worker is usually a good fit when the main thread would otherwise freeze for tens or hundreds of milliseconds or more.
9. Practical Mini Project
Here is a complete browser example that filters a list of numbers in a worker and displays the result without blocking the page. The page lets the user click a button while the worker does the heavy work.
// main.js
const status = document.querySelector("#status");
const output = document.querySelector("#output");
const button = document.querySelector("#run");
const worker = new Worker("./filter-worker.js");
worker.onmessage = (event) => {
status.textContent = "Done";
output.textContent = `Found ${event.data.count} values`;
worker.terminate();
};
button.addEventListener("click", () => {
status.textContent = "Working...";
const numbers = Array.from({ length: 500000 }, (_, index) => index);
worker.postMessage({ numbers, threshold: 499000 });
});
// filter-worker.js
self.onmessage = (event) => {
const { numbers, threshold } = event.data;
const matches = numbers.filter((value) => value > threshold);
self.postMessage({ count: matches.length });
};This mini project shows the full worker loop: the main thread prepares data, the worker performs the expensive filter, and the result comes back asynchronously.
10. Key Points
- Web Workers move heavy JavaScript off the main thread.
- They communicate by sending messages, not by direct function calls.
- Workers cannot use the DOM directly.
- Transferable objects are useful for large binary data.
- Workers should be reserved for tasks heavy enough to impact responsiveness.
11. Practice Exercise
- Create a worker that receives an array of numbers and returns the largest value.
- Update the page to display the result without blocking a button click or input field.
- Add an error handler so missing worker files are reported clearly.
Expected output: Clicking the button shows the largest number in the array, and the UI remains responsive while the worker runs.
Hint: Use postMessage to send the numbers to the worker and onmessage to receive the result.
// main.js
const result = document.querySelector("#result");
const button = document.querySelector("#find");
const worker = new Worker("./max-worker.js");
worker.onmessage = (event) => {
result.textContent = `Largest number: ${event.data.max}`;
};
worker.onerror => {
result.textContent = "Worker failed to load.";
};
button.addEventListener("click", () => {
const numbers = [12, 89, 45, 101, 63];
worker.postMessage({ numbers });
});
// max-worker.js
self.onmessage = (event) => {
const max = event.data.numbers.reduce((highest, value) => {
return value > highest ? value : highest;
}, -Infinity);
self.postMessage({ max });
};12. Final Summary
Web Workers are a browser API for moving CPU-heavy JavaScript off the main thread. They help keep pages responsive during expensive work such as large calculations, parsing, filtering, and binary data processing.
The key mental model is that a worker is isolated: it cannot touch the DOM, and it talks to the page through messages. That makes it excellent for background computation but unsuitable for direct UI manipulation. When you need to reduce jank, structure your code so the page gathers input, the worker processes it, and the page applies the result.
If you want to keep learning, the next useful topic is how message passing works with the structured clone algorithm and transferable objects, because those details determine what data you can move efficiently between the main thread and a worker.