Drag a slider on a page that's mid-way through parsing a 50,000-row CSV in JavaScript, and nothing happens for a full second. Not because the slider's code is slow — it never even runs. The browser's one JavaScript thread is busy with your parsing loop, and until that loop returns, no click, no scroll, no repaint gets a turn. The fix already ships in every browser you support: a second thread, called a Web Worker, that runs your code without ever touching the one thread the page's UI depends on.
What you'll learn
By the end of this guide you'll be able to:
- Explain why the browser has exactly one thread for JavaScript and the DOM, and why that thread stalls the whole page under heavy work
- Create a Web Worker, send it data, and get a result back without blocking the main thread
- Reason correctly about what does and doesn't survive the trip between threads (structured cloning vs. transferable objects)
- Handle worker errors, terminate workers cleanly, and avoid the memory leaks that come from forgetting to
- Decide, with real judgment, when a worker is worth the complexity and when it isn't
Who this is for: you've written addEventListener handlers and used fetch, and you've felt a UI stutter you couldn't explain.
Contents
- Why Web Workers exist
- The mental model
- Building a worker, stage by stage
- Edge cases and gotchas
- Best practices
- FAQ
- Cheat sheet
- Key takeaways
Why Web Workers exist
JavaScript in the browser runs on a single thread — the main thread — and that same thread is also responsible for parsing HTML, computing styles, laying out and painting pixels, and responding to input. When your code runs, everything else waits. Here's the naïve version of the CSV problem from the opening line:
// the wrong way — this blocks everything else on the page
function sumColumn(rows, columnIndex) {
let total = 0;
for (let i = 0; i < rows.length; i++) {
total += Number(rows[i][columnIndex]);
}
return total; // for 50,000+ rows, this can easily take 500ms–2s
}
button.addEventListener("click", () => {
const total = sumColumn(hugeDataset, 3); // main thread stalls here
resultEl.textContent = total;
});
While sumColumn runs, the browser cannot repaint, cannot fire scroll or click handlers, and cannot update anything on screen — including a "loading" spinner you might have shown a moment earlier. The tab looks frozen because, for that stretch of time, it is. setTimeout(fn, 0) doesn't help either: it still runs the callback on the same main thread, just slightly later; it defers the freeze, it doesn't remove it.
A Web Worker solves this by giving that expensive loop its own thread, with its own JavaScript engine instance, running in parallel with the main thread. The main thread stays free to paint and respond to input the entire time.
The mental model
The mental model: a Web Worker is a separate JavaScript environment, running in parallel, that shares no memory with the page — the only way in or out is sending copies of data through a message channel.
Picture two rooms with no shared furniture and no window between them, connected by a mail slot. You can pass a note through the slot (postMessage), and the other room can read it and mail one back (onmessage). Neither room can reach into the other and grab a variable, call a function, or touch a DOM element sitting in the other room — because nothing is actually shared. What crosses the slot is a copy of the data, produced by an algorithm called structured cloning, not a reference to the original.
That single fact — no shared memory, only copied messages — explains almost every rule that follows: why a worker can't touch the DOM (the DOM objects live in the main thread's room), why you can't pass a function to a worker (functions aren't cloneable), and why very large payloads need a different trick (transferable objects, covered below).
Building a worker, stage by stage
Stage 1: the smallest working worker
A worker is created from a separate script file:
// main.js
const worker = new Worker("sum-worker.js");
worker.postMessage({ rows: hugeDataset, columnIndex: 3 }); // send a copy of the data
worker.onmessage = (event) => {
resultEl.textContent = event.data.total; // runs once the worker replies
};
// sum-worker.js — runs on its own thread
self.onmessage = (event) => {
const { rows, columnIndex } = event.data;
let total = 0;
for (let i = 0; i < rows.length; i++) {
total += Number(rows[i][columnIndex]);
}
self.postMessage({ total }); // send the result back as a message
};
Key concept: the worker file has no access to
windowor the DOM — inside it,selfrefers to the worker's own global scope (DedicatedWorkerGlobalScope), not the page.
Clicking the button now posts the data to the worker and returns immediately; the main thread never blocks. The heavy loop runs on the worker's thread, and the page keeps painting and responding to input the whole time.
Stage 2: loading a worker without a separate file
You don't always want a second network request for a small worker script. You can build one from a string using a Blob and an object URL:
const workerSource = `
self.onmessage = (event) => {
const n = event.data;
self.postMessage(fib(n));
};
function fib(n) { return n < 2 ? n : fib(n - 1) + fib(n - 2); }
`;
const blob = new Blob([workerSource], { type: "application/javascript" });
const worker = new Worker(URL.createObjectURL(blob));
This is how the playground below builds its worker inline — useful for demos, small utility workers, or libraries that want to ship a worker without a second file to deploy.
Stage 3: module workers
Classic workers load dependencies with the older importScripts() function. Modern browsers also support module workers, which use standard import statements, by passing { type: "module" }:
const worker = new Worker("sum-worker.js", { type: "module" });
// sum-worker.js as a module
import { sumColumn } from "./math-utils.js";
self.onmessage = (event) => {
self.postMessage(sumColumn(event.data.rows, event.data.columnIndex));
};
Module workers are supported in all current major browsers as of 2026. If you need to support an environment that predates module worker support, stick with a classic worker and importScripts().
Stage 4: transferable objects, for when copying is too slow
Structured cloning copies data. For a plain object with a few numbers, that copy is instant. For a 200 MB ArrayBuffer of audio or image data, copying it on every message becomes the new bottleneck. The fix is a transferable object: instead of copying an ArrayBuffer, you transfer ownership of it to the worker.
const buffer = new ArrayBuffer(1024 * 1024 * 50); // 50 MB
worker.postMessage(buffer, [buffer]); // second argument: the transfer list
// after this call, `buffer.byteLength` on the main thread is 0 —
// the memory now belongs to the worker, not this thread
Key concept: transferring moves the underlying memory instead of copying it, which is why it's effectively free even for huge buffers — and why the original reference becomes unusable afterward.
ArrayBuffer, MessagePort, ImageBitmap, and a few stream types are transferable; plain objects, arrays, and strings are not — they're always copied.
Stage 5: cleanup and termination
A worker keeps running (and keeps memory allocated) until you explicitly stop it:
// from the main thread
worker.terminate(); // stops the worker immediately, no matter what it's doing
// from inside the worker itself
self.close(); // the worker asks to stop itself, after finishing current work
terminate() is immediate and unconditional — any in-progress work in the worker is simply discarded, with no finally block guaranteed to run. Always terminate a worker you no longer need (e.g., when a component unmounts), or it keeps running and holding memory for the lifetime of the page.
Edge cases and gotchas
-
No DOM access, ever. A worker cannot read or write
document, cannot usewindow, and cannot manipulate any DOM node you pass it — attempting to send a DOM node throws aDataCloneError, because DOM nodes are not structured-cloneable. If a worker needs to render something, it computes data and sends it back for the main thread to draw (or usesOffscreenCanvas, a separate, more advanced API). -
Functions can't cross the boundary. You can't
postMessagea callback and have the worker invoke it. Send data in, get data out; the worker's own script defines what it does with the data. -
Errors don't throw where you'd expect. An uncaught exception inside a worker doesn't throw on the main thread — it fires an
errorevent on theWorkerobject. Always attach a handler:
worker.onerror = (event) => {
console.error("Worker crashed:", event.message, event.filename, event.lineno);
};
Without this handler, a worker that throws simply goes silent from the main thread's point of view.
-
Same-origin restriction. A classic
new Worker(url)script must be same-origin with the page (ablob:URL created by the page counts as same-origin for this purpose). You cannot point a worker directly at a third-party script URL. - Workers aren't free to start. Spinning one up has real overhead — allocating a thread, a new JS engine context, and loading the script. For a task that finishes in a few milliseconds, that overhead can cost more than the task itself. Workers pay off for work that's substantial or repeated, not for trivial one-off computations.
-
A worker can spawn its own workers, and can also use
fetch,WebSocket,setTimeout, andIndexedDB— it's a real JavaScript environment, just without the DOM. -
SharedWorkeris a different, less common API. It allows multiple tabs from the same origin to connect to one shared worker instance viaport.postMessage, instead of each tab getting its own dedicated worker. Check current browser support before relying on it — support has historically laggedWorkerandtype: "module".
Best practices
Reach for a worker when you have CPU-bound work that takes tens of milliseconds or more — parsing large files, running compression, image or audio processing, complex data transformations, cryptography, or search/filtering over large in-memory datasets — especially if it needs to run while the user keeps interacting with the page.
Avoid it when the work is already I/O-bound (a fetch call doesn't block the main thread even without a worker — the wait for the network happens off-thread already), when the data involved is small enough that structured cloning would cost more than the computation itself, or when the task is short and infrequent enough that a worker's startup cost dominates.
Keep the message contract simple. Design worker messages like a small API: a type field plus a payload, both directions. This scales cleanly to a worker that handles more than one kind of task.
worker.postMessage({ type: "PARSE_CSV", payload: csvText });
worker.postMessage({ type: "SORT_ROWS", payload: { rows, key: "date" } });
Always pair creation with cleanup. Every new Worker(...) should have a matching terminate() — in a component's unmount/cleanup hook, in a "cancel" button, or when the task naturally completes and you don't plan to reuse the worker.
FAQ
Can a Web Worker access the DOM?
No. Workers run in a context with no DOM APIs at all — no document, no window. If a worker needs something rendered, it sends data back to the main thread, which does the actual DOM update.
Do Web Workers block the main thread while they run?
No — that's their entire purpose. The worker's code executes on its own thread, in parallel with the main thread, so the page keeps painting and responding to input while the worker is busy.
Can I use fetch or WebSocket inside a worker?
Yes. Workers have access to fetch, WebSocket, setTimeout/setInterval, IndexedDB, and self.crypto, among other APIs. What they lack is anything DOM-related.
What's the difference between a Web Worker and a Service Worker?
A Worker is created and owned by one page for offloading computation and dies with that page (unless you use SharedWorker, which multiple tabs can connect to). A ServiceWorker is a different API entirely: it's registered for an origin, keeps running independently of any open tab, and exists mainly to intercept network requests and enable offline support and push notifications. They solve different problems and aren't interchangeable.
Does postMessage copy my data or send a reference to it?
By default it copies, using the structured clone algorithm — the receiving side gets an independent copy, and mutating one side afterward never affects the other. The exception is objects you explicitly list in the transfer list (like ArrayBuffer), which move instead of copying.
Can I use React, Vue, or other framework code inside a worker?
You can run plain JavaScript logic (data transforms, computation, parsing) inside a worker just fine, but you cannot render framework components there, because rendering ultimately means touching the DOM, and workers have no DOM access. Keep workers for the computation; keep rendering on the main thread.
Cheat sheet
| Task | Code | Notes |
|---|---|---|
| Create a worker | new Worker("file.js") |
Script must be same-origin (or a blob: URL) |
| Create a module worker | new Worker("file.js", { type: "module" }) |
Lets the worker use import
|
| Send data to a worker | worker.postMessage(data) |
Copies data via structured clone |
| Transfer instead of copy | worker.postMessage(buf, [buf]) |
Only for transferable types (e.g. ArrayBuffer); buf becomes unusable on the sender's side afterward |
| Receive data (main thread) | worker.onmessage = (e) => e.data |
|
| Receive data (inside worker) | self.onmessage = (e) => e.data |
self is the worker's global scope |
| Reply from a worker | self.postMessage(result) |
|
| Handle a worker crash | worker.onerror = (e) => {...} |
Uncaught worker exceptions surface here, not as a thrown error |
| Stop a worker (from outside) | worker.terminate() |
Immediate; no cleanup code inside the worker runs |
| Stop a worker (from inside) | self.close() |
The worker finishes and exits |
// the whole pattern, copy-paste ready
const worker = new Worker("worker.js");
worker.postMessage({ type: "RUN", payload: someData });
worker.onmessage = (event) => {
console.log("Result:", event.data);
};
worker.onerror = (event) => {
console.error("Worker error:", event.message);
};
// later, when you're done with it
worker.terminate();
🎮 Try it yourself
▶️ Open the interactive playground →
Runs right in your browser — poke at it and watch the concept react live.
Key takeaways
- The browser has one thread for JavaScript and the DOM; anything expensive on it stalls painting, scrolling, and clicks.
- A Web Worker is a separate thread with no shared memory — data crosses via
postMessage, copied by the structured clone algorithm. - Functions and DOM nodes can never cross that boundary; only cloneable data can, unless you explicitly transfer a supported type like
ArrayBuffer. - Always attach
onerror, and alwaysterminate()a worker you're done with — an uncleaned-up worker keeps running and holding memory. - Workers earn their overhead on substantial CPU-bound work, not on small or I/O-bound tasks that were never blocking the main thread to begin with.
🧠 Test yourself
Think it clicked? Take the 8-question quiz →
Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.
That slider from the opening paragraph can stay responsive through the exact same 50,000-row parse — move the loop into a worker, send the rows over with postMessage, and the main thread never has a reason to stall. What's the heaviest loop in your own codebase that's still running where the UI can feel it?
🚀 Want more like this? Every guide, playground, and quiz lives on bestpractic.org — open it and sign up free so the next one finds you.
Thanks for reading! Let's stay connected:
- ⭐ GitHub — follow me and star the projects: github.com/parsajiravand
- 💬 Discord — join the frontend best-practices community: discord.gg/d9KRhuAwQ
- 📸 Instagram — frontend best practices, daily: @bestpractice___
Top comments (2)
Solid overview of thread offloading. One critical edge case in production is the serialization overhead of Structured Clone. If you're passing large nested objects or tens of thousands of JSON records between the main thread and a worker, the structured clone algorithm itself can block the main thread for 50-100ms during serialization/deserialization.
Whenever possible for high-throughput pipelines (like audio, image transforms, or heavy binary parsing), using
ArrayBufferwith Transferable Objects (passing the buffer ownership so zero-copy transfer occurs) orSharedArrayBuffer+Atomicsavoids this clone penalty entirely. Also worth mentioning that workers have a non-trivial cold start instantiation cost, so spinning up worker pools (reusing worker instances) rather than instantiating ad-hoc per task is essential to prevent CPU thrashing.Great breakdown of Web Workers! One practical nuance worth highlighting when dealing with massive datasets (like the 50k CSV example) is the serialization overhead of structured cloning. For truly heavy payloads, passing an
ArrayBufferas a Transferable object (zero-copy memory transfer) or leveragingReadableStreamacross threads avoids the CPU hit of cloning large JSON-like trees on the main thread before the worker even starts processing.