DEV Community

Cover image for Web Workers in JavaScript: The Complete Guide
Parsa Jiravand
Parsa Jiravand

Posted on Originally published at bestpractic.org

Web Workers in JavaScript: The Complete Guide

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

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;
});
Enter fullscreen mode Exit fullscreen mode

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
};
Enter fullscreen mode Exit fullscreen mode
// 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
};
Enter fullscreen mode Exit fullscreen mode

Key concept: the worker file has no access to window or the DOM — inside it, self refers 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));
Enter fullscreen mode Exit fullscreen mode

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" });
Enter fullscreen mode Exit fullscreen mode
// sum-worker.js as a module
import { sumColumn } from "./math-utils.js";

self.onmessage = (event) => {
  self.postMessage(sumColumn(event.data.rows, event.data.columnIndex));
};
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 use window, and cannot manipulate any DOM node you pass it — attempting to send a DOM node throws a DataCloneError, 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 uses OffscreenCanvas, a separate, more advanced API).
  • Functions can't cross the boundary. You can't postMessage a 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 error event on the Worker object. Always attach a handler:
  worker.onerror = (event) => {
    console.error("Worker crashed:", event.message, event.filename, event.lineno);
  };
Enter fullscreen mode Exit fullscreen mode

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 (a blob: 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, and IndexedDB — it's a real JavaScript environment, just without the DOM.
  • SharedWorker is a different, less common API. It allows multiple tabs from the same origin to connect to one shared worker instance via port.postMessage, instead of each tab getting its own dedicated worker. Check current browser support before relying on it — support has historically lagged Worker and type: "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" } });
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

🎮 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 always terminate() 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:

Top comments (2)

Collapse
 
elvingts profile image
Adrian

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 ArrayBuffer with Transferable Objects (passing the buffer ownership so zero-copy transfer occurs) or SharedArrayBuffer + Atomics avoids 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.

Collapse
 
elvingts profile image
Adrian

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 ArrayBuffer as a Transferable object (zero-copy memory transfer) or leveraging ReadableStream across threads avoids the CPU hit of cloning large JSON-like trees on the main thread before the worker even starts processing.