Web Workers in JavaScript: The Complete Guide
Learn JavaScript Web Workers step by step: message passing, transferable objects, error handling, and a copy-paste cheat sheet for offloading work.

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.
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.
- Why Web Workers exist
- The mental model
- Building a worker, stage by stage
- Edge cases and gotchas
- Best practices
- FAQ
- Cheat sheet
- Key takeaways
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:
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: 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).
A worker is created from a separate script file:
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.
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:
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.
Classic workers load dependencies with the older importScripts() function. Modern browsers also support module workers, which use standard import statements, by passing { type: "module" }:
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().
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.
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.
A worker keeps running (and keeps memory allocated) until you explicitly stop it:
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.
- 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:js 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".
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.
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.
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.
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.
Yes. Workers have access to fetch, WebSocket, setTimeout/setInterval, IndexedDB, and self.crypto, among other APIs. What they lack is anything DOM-related.
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.
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.
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.
| 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 |
Runs right in your browser — poke at it and watch the concept react live.
- 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.
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___
Keep reading
One post a day, in your inbox
Each one with a runnable playground and a quiz. No pitch, no digest, unsubscribe in one click.
0 comments
Sign in to join the discussion, like comments, and save articles for later.