completefrontend
Open lab My progress
THE JAVASCRIPT FIELD GUIDE

Web Workers

Learn how Web Workers keep browser interfaces responsive by running CPU-heavy JavaScript on another thread, passing structured-clone messages, and using transferables safely.

You will be able to
  • 01
    Move heavy work off the pageCreate a worker, send it work, get the result back, handle errors, and stop the worker when you no longer need it.
  • 02
    Send the right dataExplain how messages are copied (structured clone), why functions and DOM elements can't be sent, and when a transfer saves a copy.
  • 03
    Choose workers on purposeCompare classic and module workers, list what a worker can use, and spot jobs that should stay on the main thread.

A second thread for heavy work

A thread is a line of work that the computer runs one step at a time. Every browser page has one main line of work, called the main thread. It does most of the page's jobs:

  • It runs most of your JavaScript.
  • It reacts to clicks and key presses.
  • It changes the DOM, the tree of elements that make up the page.
  • It works out the layout and paints the screen.

The main thread does one job at a time. If your code runs a long calculation there, every other job waits. Clicks do nothing. Animations stop. The page feels stuck.

A Web Worker is a second place to run JavaScript, on another thread. While the worker calculates, the main thread stays free for clicks and painting. This lesson uses dedicated workers. A dedicated worker belongs to the one page that made it.

The page and the worker don't share variables, and the worker can't touch the DOM. They talk by sending messages to each other with postMessage.

Real-life analogyA restaurant with one waiter and a kitchen helper

A small restaurant has one waiter. If the waiter goes to the kitchen to chop onions for ten minutes, every table waits. So the owner hires a kitchen helper. Now the helper chops the onions, and the waiter keeps serving the tables.

In real life: The only waiter serves the tables
In JavaScript: The main thread handles clicks and the screen
In real life: The helper chops vegetables in the back
In JavaScript: A worker runs long calculations away from the page
In real life: The helper never talks to customers
In JavaScript: A worker can't touch the DOM, so it can't call document.querySelector
In real life: Tickets pass through the kitchen window
In JavaScript: Messages pass with postMessage

In this lesson you will learn:

  • how to create a worker, send it work, and stop it;
  • how messages are copied on the way (this is called structured clone);
  • how to hand data over without copying it (transferable objects);
  • the two kinds of worker: classic workers and module workers.

The lesson also uses the event loop: the way the browser picks the next job to run, one at a time. If that idea is new, keep the Event loop lesson open beside this one.

Creating and stopping a dedicated worker

FOUNDATION

You make a dedicated worker with new Worker(url). The url is the address of the worker's own JavaScript file. In a real app, your bundler (the build tool that packs your files) often makes that address for you.

The playgrounds on this page can't load extra files. So each one puts its worker code into a Blob URL. A Blob URL is a short-lived address that the browser makes for text kept in memory.

The code below shows both sides. The top part runs on the page. The bottom part runs inside the worker.

The page sends work, and the worker sends back a resultPop out in the code editor (opens in a new tab)JavaScript
// main.jsconst worker = new Worker(workerUrl); worker.postMessage({ type: "count", limit: 120000 }); worker.onmessage = (event) => {  renderResult(event.data);}; worker.onerror = (event) => {  console.error(event.message);}; // worker.jsself.onmessage = (event) => {  const result = doHeavyWork(event.data.limit);  self.postMessage(result);};

Line by line:

  • Line 2 creates the worker.
  • Line 4 sends it a message: an object that asks it to count up to 120000.
  • Lines 6 to 8 run when the worker replies. The reply is in event.data.
  • Lines 10 to 12 run if the worker's code throws an error.
  • Lines 15 to 18 are the worker's side. self is the worker's own global object, like window on a page. When a message arrives, the worker does the work and sends the result back with self.postMessage.
Left: the page, run by the main thread, handles clicks and the DOM. Right: the worker, on another thread, runs doHeavyWork. An arrow labelled postMessage goes from the page to the worker. An arrow labelled result comes back. The page stays free; the worker has no DOM.pagemain threadclicks, DOMstays freepostMessageresultworkeranother threaddoHeavyWork()no DOM
The page sends the job with postMessage, and the worker sends the result back. The main thread stays free for clicks, but only it can touch the DOM.

The next code puts the page's side in order. Line 1 creates the worker. Lines 2 and 3 set up the reply and error handlers first. Line 4 sends the work. Line 7 stops the worker later, once its work is no longer needed.

Handle replies and errors, then stop the workerJavaScript
const worker = new Worker(workerUrl);worker.onmessage = (event) => console.log(event.data);worker.onerror = (event) => console.error(event.message);worker.postMessage({ type: "count", limit: 10 }); // Later, when this work is stale:worker.terminate();

A dedicated worker has many of the tools a page has. It has:

  • postMessage and onmessage, for messages;
  • timers such as setTimeout;
  • fetch, for network requests;
  • IndexedDB, the browser's built-in database.

It does not have document, the page's window, the layout, or your React state.

Two events on the Worker object tell you something went wrong:

  • error fires when the worker's code throws, or when its script fails to load.
  • messageerror fires when a message arrives but can't be turned back into a value.

One more problem shows up sooner. If postMessage can't copy a value, it throws an error right away, on the side that sent it.

Stop workers you no longer need

A worker keeps running until something stops it, even after its result is no longer useful. Call worker.terminate() when the work is out of date, when the user resets an experiment, or when a component leaves the page. terminate() stops the worker at once.

The page calls terminate(). Inside the worker, self.close() lets the worker stop itself after it finishes its current message. Neither one sends a last reply for you. If the page needs to know the worker stopped, send your own "done" message.

Jank lab: count primes without freezing the page

INTERACTIVE

Jank means the page stutters or freezes because the screen wasn't redrawn in time. To show it, this lab counts prime numbers: numbers that divide evenly only by 1 and by themselves. Counting them is pure JavaScript work. It needs no network, only the computer's processor, the CPU.

Try it:

  1. Click Run on main thread. The moving dot stops until the count ends.
  2. Click Run in worker. The same count runs in a worker, and the dot keeps moving.
Jank lab: count primes without freezing the page
The same job in two placesPop out in the code editor (opens in a new tab)JavaScript
function countPrimesUpTo(limit) {  let count = 0;  for (let value = 2; value <= limit; value += 1) {    if (isPrime(value)) count += 1;  }  return count;} // Main thread: blocks clicks, animation, and paint until done.const total = countPrimesUpTo(120000); // Worker thread: the same CPU work runs away from the UI.worker.postMessage({ type: "count", limit: 120000 });
Moving dot120,000
  • Run the main-thread version, then the worker version. Watch the moving dot.
Try it yourself

The main-thread button blocks the page on purpose. The worker button makes a worker from a Blob URL, sends it the same job, and stops it after the reply. Times depend on your computer.

Keep the number small enough. The goal is a pause you can see, not a hot laptop.

The worker still uses the CPU. The work doesn't disappear. The gain is that the main thread stays free for clicks and painting.

Workers also cost something. Starting a worker takes time, and so does sending each message. So don't make a worker for every small loop.

Messages and structured clone

INTERACTIVE

To send a message, one side calls postMessage(value). A moment later, the other side gets a message event. The value is in the event's data property, event.data.

The value itself doesn't travel. The browser makes a copy of it, using a fixed list of steps called structured clone. Structured clone can copy more kinds of values than JSON can:

  • dates (Date);
  • Map and Set;
  • ArrayBuffer, a block of raw bytes;
  • arrays and plain objects, even nested ones.

You can run the same copy steps yourself with structuredClone(value). The small example below uses it to show what a worker would get.

The copy keeps the old valuePop out in the code editor (opens in a new tab)JavaScript
const order = { item: "tea" };const copy = structuredClone(order);order.item = "coffee";console.log(copy.item);console.log(order.item);

Line by line:

  • Line 1 makes an order object with item: "tea".
  • Line 2 makes a separate copy, copy.
  • Line 3 changes the original to "coffee".
  • Line 4 prints tea, because the copy didn't change. Line 5 prints coffee.

A worker works the same way. It gets the value as it was when you posted it. Later changes to the original don't reach it.

The order cart now holds item coffee. An arrow labelled structuredClone points to the copy cart, which still holds item tea.item: "coffee"orderchanged on line 3structuredCloneitem: "tea"copystill tea
Line 2 copies order while it still says tea. Line 3 changes only the original, so copy.item is still "tea".
Real-life analogyOrder tickets through the kitchen window

The waiter writes the order in a notebook, tears off a ticket, and passes it through the kitchen window. The helper cooks from that ticket. If the waiter changes the notebook afterward, the ticket in the kitchen stays the same.

In real life: The waiter writes an order ticket
In JavaScript: The page builds a message object
In real life: The helper gets a copy of the ticket
In JavaScript: The worker gets a structured-clone copy
In real life: Changing the notebook later doesn't change the ticket
In JavaScript: Changing the original after postMessage doesn't change the copy
Message lab: the worker gets a copy
What structured clone can and can't copyPop out in the code editor (opens in a new tab)JavaScript
const original = {  created: new Date("2026-09-25T08:30:00.000Z"),  labels: new Map([["level", "warm-up"]]),  nested: ["ticket", { count: 1 }],}; worker.postMessage(original);original.nested[1].count = 99; // The worker receives a structured-clone copy with count still 1.worker.postMessage(() => "functions cannot be cloned");
Message logreal worker
  • Send an object to a worker. Then compare the worker's copy with the changed original.
Try it yourself

Structured clone keeps types like Date and Map, but it sends a copy. Changing the original afterward doesn't change the worker's copy.

The DataCloneError is the browser refusing to copy a function.
Structured clone is not JSON

JSON turns everything into plain text. A Date becomes a string, and a Map becomes an empty object. Structured clone keeps those types as they are.

But structured clone can't copy functions or DOM elements. If you try, postMessage throws a DataCloneError on the sending side, and nothing reaches the worker.

Transferables: hand data over instead of copying it

INTERACTIVE

Copying is fine for small values. For a big block of bytes, copying takes time and uses twice the memory. Some objects are transferable. The browser moves them to the receiver instead of copying them, and the sender gives them up. These are transferable:

  • ArrayBuffer, a block of raw bytes;
  • MessagePort, one end of a private message channel;
  • ImageBitmap, an image that is ready to draw;
  • OffscreenCanvas, a canvas that can draw away from the page.

After you transfer an ArrayBuffer, the sender's buffer is detached: it is empty. Its byteLength, the number of bytes it holds, becomes 0.

After a transfer, the sender's buffer is emptyPop out in the code editor (opens in a new tab)JavaScript
const buffer = new ArrayBuffer(4);const copy = structuredClone(buffer, { transfer: [buffer] });console.log(buffer.byteLength);console.log(copy.byteLength);

Line by line:

  • Line 1 makes a buffer that holds 4 bytes.
  • Line 2 passes it to structuredClone with the option { transfer: [buffer] }. That option says: move this buffer, don't copy it. With a worker, you write the same thing as worker.postMessage(buffer, [buffer]).
  • Line 3 prints 0. The sender's buffer is empty now.
  • Line 4 prints 4. The receiver has the bytes.

So never transfer a buffer that the page still needs to read.

The buffer box holds a card that says 0 bytes. An arrow labelled transfer goes to the copy box, which holds a card that says 4 bytes.0 bytesbufferempty nowtransfer4 bytescopyhas the bytes
The transfer moves the 4 bytes into copy. The sender's buffer is left empty, so its byteLength is 0.
Real-life analogyThe real pot, not a photocopy

If the kitchen needs a recipe, a photocopy is fine. If it needs the big soup pot, nobody makes a second pot. The waiter hands over the real pot. After that, the waiter can't serve soup from it.

In real life: Photocopying a recipe
In JavaScript: Structured clone copies the data
In real life: Handing over the real soup pot
In JavaScript: A transfer moves a transferable object
In real life: The waiter no longer has the pot
In JavaScript: The sender's transferred ArrayBuffer is empty
Transfer lab: copy the bytes or move them
Copy first, then transferPop out in the code editor (opens in a new tab)JavaScript
const buffer = new ArrayBuffer(4 * 1024 * 1024); worker.postMessage({ buffer });console.log(buffer.byteLength); // sender still owns it const fastBuffer = new ArrayBuffer(4 * 1024 * 1024);worker.postMessage({ buffer: fastBuffer }, [fastBuffer]);console.log(fastBuffer.byteLength); // 0: ownership transferred
Byte lengths4 MB
  • Compare copying the bytes with moving them.
Try it yourself

Without a transfer list, the sender keeps its buffer. With a transfer list, the bytes move to the worker, so the sender's byteLength becomes 0.

Moving big binary data can be much faster than copying it. Do it only when the page doesn't need the bytes any more.

There is one way to truly share memory: SharedArrayBuffer. Both sides read and write the same bytes. Browsers allow it only on pages that are cross-origin isolated. That means the server sends two special response headers:

  • Cross-Origin-Opener-Policy: same-origin
  • Cross-Origin-Embedder-Policy: require-corp

Check the global crossOriginIsolated before you use SharedArrayBuffer. This page's code editor runs in a locked-down frame on purpose, so it doesn't have it. Shared memory is an advanced topic. Copying and transferring cover most needs.

Classic workers and module workers

INTERACTIVE

There are two kinds of worker. They differ only in how they load code:

  • A classic worker is made with new Worker(url). It loads a normal script. To load more scripts, it calls importScripts("helper.js"). That loads and runs each script at once, before the next line runs.
  • A module worker is made with new Worker(url, { type: "module" }). It loads a JavaScript module, so it can use import and export in browsers that support them.
Module worker lab: try both kinds
Make a classic worker, then try a module workerJavaScript
const classic = new Worker(classicUrl);// classic workers use importScripts("helper.js") when they need scripts. try {  const moduleWorker = new Worker(moduleUrl, { type: "module" });  moduleWorker.postMessage("ping");} catch (error) {  console.log("module workers are not available here");}
Support checkBlob URL
  • Try making a classic worker and a module worker in this browser.
Try it yourself

A classic worker loads a normal script and can use importScripts. A module worker loads a module, can use import and export, and is made with { type: 'module' }.

To check support, try to make the worker inside try/catch and listen for its error event. Support depends on the browser and the page's rules.

Both kinds follow the same safety rule: neither one can touch the DOM. The choice is only about how your code is loaded and bundled.

In a real project, check two things:

  • how your bundler makes worker URLs;
  • your site's Content Security Policy (CSP): rules that say where the page may load scripts from. Some CSPs block blob: workers.

This site's CSP doesn't block blob: workers, so the labs can use Blob URLs. After a lab stops a worker, it also revokes (cancels) the worker's Blob URL. Then the browser can let go of the script text.

importScripts() works only in classic workers. A module worker uses import lines at the top of the file instead. Mixing the two is a common bug when a project switches from classic to module workers.

The message timeline

STEP THROUGH

postMessage doesn't call the worker right away. It puts the message in a queue, a waiting line, and returns. Then things happen in this order:

  1. The rest of the current script keeps running.
  2. Microtasks, such as promise callbacks, run right after the script, as usual (see the Promises lesson).
  3. The worker's onmessage runs later, in the worker's own event loop.
  4. The worker's reply comes back even later, as a new message event on the Worker object.
The main thread does three steps: 1, postMessage; 2, push pie; 3, the script ends. A queued copy holding salad, soup waits between the threads. Later the worker runs step 4, onmessage, and logs salad, soup.main thread1. postMessage2. push pie3. script endsqueued copysalad, souplaterworker4. onmessagelogs salad, soup
postMessage only queues a copy of the ticket, and the main script keeps going. The worker's onmessage runs later and sees salad and soup, without pie.

Step through the code below and watch when each line runs.

Step through the message timeline
Step 0 of 7Ready
Your turn: follow the blue line

The main thread posts and keeps going. The worker handles the message later, on its own thread. The reply comes back as another message.

Running in
  1. script
Next: line 1
console.log("main: before postMessage");worker.postMessage(ticket);ticket.items.push("pie");console.log("main: changed original"); // later, in the worker event loopself.onmessage = (event) => {  console.log(event.data.items.join(", "));  self.postMessage("done");};
CallStoreChangeResultBlue line: runs next. Ran: already executed.
Recent returnsNothing yet. Start with the blue line.
How the message is sent

Guess what the main thread prints before the worker's onmessage runs.

A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.

Where workers fit in real apps

PRACTICAL

Workers are best for jobs that:

  • take a lot of CPU time;
  • don't need the DOM;
  • take data in and give data back.

Some examples: reading a big file, searching many records, making small preview images, compressing images, crunching numbers, or sorting a very large table.

A normal fetch already waits without blocking the main thread. A worker helps only if the response needs heavy work afterward.

A SharedWorker is a different kind of worker. Several pages from the same site (the same origin) can connect to one shared worker. Each page talks to it through its own MessagePort, the page's end of a message channel.

  • Inside the shared worker, a connect event fires for each page that connects.
  • Each port must be started with start(), or given an onmessage handler, before messages flow.

Use a shared worker to coordinate several pages, not as a faster dedicated worker.

Both sides of a shared workerJavaScript
const shared = new SharedWorker("shared-worker.js");shared.port.onmessage = (event) => console.log(event.data);shared.port.start();shared.port.postMessage({ type: "lookup", key: "tea" }); // shared-worker.jsself.onconnect = (event) => {  const port = event.ports[0];  port.onmessage = () => port.postMessage("ready");};

This example can't run here. It needs a separate shared-worker.js file on a real site. The dedicated Blob workers above are the ones that run on this page.

Sorter: pick the right place for the work
Sort here, or send the list to a workerPop out in the code editor (opens in a new tab)JavaScript
const names = ["Grace", "Ada", "Linus", "Brendan", "Hedy"]; // Small list: keep it simple.const sortedHere = [...names].sort((a, b) => a.localeCompare(b)); // Large list: send a copy and render the reply.worker.postMessage(names);worker.onmessage = (event) => renderRows(event.data);
Rows5 names
  1. Grace
  2. Ada
  3. Linus
  4. Brendan
  5. Hedy
Try it yourself

Sort a small list on the main thread, or send it to a worker. For a list this small, a worker is only for practice.

A worker is worth it when the list is huge or each comparison is slow. Small sorts are simpler on the main thread.
Main thread or worker?
  • Change a button's text after a click
  • Read a 20 MB export file before showing a summary
  • Draw pixels with OffscreenCanvas where the browser supports it
  • Fetch JSON while the user waits
  • Hide API keys from the user
  • Sort 100,000 rows for a table
  • Call alert() from background code
  • Format six dates for a settings page
Try it yourself
0 of 8 correct

Put each job where it belongs. Ask three things: does it need the DOM? Is it big work? Does the browser already do it without blocking?

Choose a category for every card. You can change an answer at any time; Reset clears them all.

The table below compares the two threads. Read each row as a question, then the answer for each side.

What the main thread and a worker can each do
What you want to knowOn the main threadIn a worker
Can it change the page?Yes. It has document, the layout, events, and painting.No. It has no document and no page window.
What is it good at?Small page updates, event handlers, and starting async work.Long calculations and reading big data.
How do you talk to it?You call its functions directly.You send messages with postMessage and listen for message events.
How does it stop?Your function returns.worker.terminate() stops it at once.

Common misconceptions

  • "Workers can update the page." They can't touch the DOM. The worker sends a message back, and the main thread updates the page.
  • "Workers make code faster." They keep the page free to respond. The total time may get shorter, stay the same, or even get longer for small jobs.
  • "Messages share objects." Normal messages are copies. Later changes to the original don't reach the copy.
  • "A transfer is just a faster copy." A transfer moves the object. The sender no longer has it.
  • "A worker can hide secrets." Worker code and its messages still run in the user's browser, where the user can read them.
  • "Service workers are the same thing." A dedicated worker helps one page calculate. A service worker sits between your pages and the network. That is the next lesson.

Practice exercises

5 EXERCISES
Exercise 1 · Warm-upPredict the copy

An object is copied, and then the original is changed. Predict what the two console.log lines print.

Starter codePop out in the code editor (opens in a new tab)JavaScript
const original = { nested: { count: 1 } };
const copy = structuredClone(original);
original.nested.count = 9;
console.log(copy.nested.count);
console.log(original.nested.count);

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · PracticeSplit the work between page and worker

    A page needs to sort table rows by score. Write which part runs on the main thread and which part runs in the worker.

    Starter codePop out in the code editor (opens in a new tab)JavaScript
    // Goal: keep the page free to respond while sorting rows.
    worker.postMessage(rows);
    worker.onmessage = (event) => renderRows(event.data);
      Exercise 3 · PracticeFind the transfer result

      Predict the two byte lengths after an ArrayBuffer is transferred.

      Starter codePop out in the code editor (opens in a new tab)JavaScript
      const buffer = new ArrayBuffer(4);
      const copy = structuredClone(buffer, { transfer: [buffer] });
      console.log(buffer.byteLength);
      console.log(copy.byteLength);

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeFind the bug: the worker touches the DOM

        Answer the quick check. Then fix the code so the page shows "Done".

        Starter codePop out in the code editor (opens in a new tab)JavaScript
        // worker.js
        self.onmessage = () => {
          const status = document.querySelector("#status");
          status.textContent = "Done";
        };

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeOrder the reply

          In this code, setTimeout stands in for a worker's reply. Predict which line prints second.

          Starter codePop out in the code editor (opens in a new tab)JavaScript
          console.log("main start");
          queueMicrotask(() => console.log("main microtask"));
          setTimeout(() => console.log("worker reply event"), 0);
          console.log("main end");

          Answer, then press Check. Spacing and letter case don’t matter.

            Check your understanding

            8 QUESTIONS
            Web Workers quiz · 8 questionsScore: first tries count
            1. Question 1 of 8What problem does a Web Worker solve?

              Choose an answer to see the explanation.

            2. Question 2 of 8What does this structured-clone example print?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              const original = { nested: { count: 1 } };
              const copy = structuredClone(original);
              original.nested.count = 9;
              console.log(copy.nested.count);
              console.log(original.nested.count);

              Choose an answer to see the explanation.

            3. Question 3 of 8Which value can't be sent with structured clone?

              Choose an answer to see the explanation.

            4. Question 4 of 8What does this ArrayBuffer transfer print?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              const buffer = new ArrayBuffer(4);
              const copy = structuredClone(buffer, { transfer: [buffer] });
              console.log(buffer.byteLength);
              console.log(copy.byteLength);

              Choose an answer to see the explanation.

            5. Question 5 of 8What happens when you call worker.terminate()?

              Choose an answer to see the explanation.

            6. Question 6 of 8Which of these can a dedicated worker use?

              Choose an answer to see the explanation.

            7. Question 7 of 8Which sentence about module workers is true?

              Choose an answer to see the explanation.

            8. Question 8 of 8In what order does this code print? (setTimeout stands in for the worker's reply.)

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              console.log("main start");
              queueMicrotask(() => console.log("main microtask"));
              setTimeout(() => console.log("worker reply event"), 0);
              console.log("main end");

              Choose an answer to see the explanation.

            Key takeaways

            • A dedicated worker lets a page run long calculations away from the main thread, so the page stays free to respond.
            • The page and the worker talk with postMessage. A normal message is a structured-clone copy.
            • A transfer moves an object instead of copying it. A transferred ArrayBuffer is left empty on the sender's side.
            • Workers have timers, fetch, and IndexedDB, but no DOM.
            • Stop workers with terminate() when their work is out of date, and listen for error events.

            Final definition.
            A Web Worker runs JavaScript on its own thread, apart from the page's main thread. The page and the worker talk only by sending messages.

            Up next: Service workers and offline apps.

            CompleteFrontend Clear concepts. Working examples.