completefrontend
Open lab My progress
THE JAVASCRIPT FIELD GUIDE

Parallel patterns with workers

Learn worker pools, request IDs, transferables, and WebAssembly threads so browser work can move without blocking the interface.

By the end, you can
  • 01
    Share out jobs with a poolExplain why a pool reuses a fixed number of workers, and work out when the lesson's pool model finishes.
  • 02
    Match replies with IDsGive each request an ID, so every reply and error reaches the promise that asked for it.
  • 03
    Choose how to send dataChoose between copying, moving (transfer), and sharing data, and say what Wasm threads need.

Doing work in parallel needs a plan

A web page runs your JavaScript on one main thread. A thread is one line of work that does one thing at a time. The main thread answers taps and clicks, changes the page, and draws each new frame on the screen. If a long calculation runs there, the page freezes until it ends.

A worker is a second place to run JavaScript. It runs at the same time as the main thread, so the page stays smooth. Doing several jobs at the same time is called parallel work. A worker helps when a job is big and does not depend on other jobs. For a tiny job, starting a worker and sending it messages can cost more than the job itself.

The main idea

A parallel pattern is a plan you can reuse for work in workers. It says how to split the work into separate jobs, how to send each job to a worker, and how to collect the results. It also says who owns each piece of data, and how an error comes back.

This lesson uses four building blocks:

  • A worker pool is a fixed team of workers that you start once and reuse for many jobs.
  • A remote procedure call (RPC) lets you ask a worker for something as if you called a function. You get a promise back.
  • A transfer moves data to a worker instead of copying it.
  • WebAssembly (Wasm) threads let several workers read and write the same memory. They are for special, heavy programs.

A worker can't touch the page. It can't read or change the DOM, the tree of elements on the page. The page and the worker talk only by sending messages with postMessage. Normally each message is copied on the way. That copy step is called a structured clone. This keeps things simple: the page handles what you see, and the worker does the heavy math. But it also means a worker does not make code faster for free.

The previous lesson, The memory model, explains why writes to shared memory need ordering rules. To review the worker basics, see Web workers and messaging and cloning.

A first message to a worker

Start with one request and one reply. The main script sends the worker an object with three things in it:

  • id is 1. It is a number that names this request.
  • task is "double". It says which job to do.
  • value is 21. It is the input for the job.

The worker reads the message and sends back the same ID with the result, 42.

self.onmessage = (event) => {  const { id, task, value } = event.data;  if (task === "double") {    self.postMessage({ id, result: value * 2 });  }};

Line 1 sets self.onmessage. That is the function the worker runs each time a message arrives. Line 2 takes the three fields out of the message. Line 3 checks that the task is "double". Line 4 sends a reply with the same ID and the result. For one request the ID changes nothing. Once several requests are on their way at once, the ID is the only way to tell the replies apart.

const worker = new Worker("worker.js"); worker.onmessage = (event) => {  console.log(event.data);}; worker.postMessage({ id: 1, task: "double", value: 21 });

Line 1 starts a worker that runs the file worker.js. Lines 3 to 5 say what to do when a reply arrives: print it. Line 7 sends the request. The console shows { id: 1, result: 42 }. You can run this in the editor, because the lesson gives it the worker.js file shown above.

Notice that the main script does not stop and wait on line 7. It sends the message and moves on, so the page can keep drawing and answering clicks. The reply arrives later, as a new event. That is why each reply needs a way to say which request it answers.

A pool: a fixed team of workers

Starting a worker takes time. The browser has to set up a new place to run code, load the worker's file, and open a channel for messages. If you start a new worker for every small job, the setup can take longer than the job.

A worker pool avoids this. It starts a small, fixed number of workers once. Each new job goes to the first worker that is free. The code below is a small model of that. It uses made-up job lengths instead of real work.

Give three jobs to two workersJavaScript
const tasks = [  { id: "tea", duration: 3 },  { id: "toast", duration: 1 },  { id: "salad", duration: 2 },]; console.log(runPool(tasks, 2).totalTime);

Lines 1 to 5 make a list of three jobs. Each job has a name and a duration, which is how many units of time it takes. Line 7 calls runPool, the lesson's model of a pool, with two workers. It prints 3:

  • Worker 1 makes tea. That takes 3 units.
  • At the same time, worker 2 makes toast (1 unit) and then salad (2 units). That is 3 units too.

The units are numbers we made up for the model. They are not times measured on a real computer.

Two workers on a time line from 0 to 3. Worker 1 makes tea, which takes 3 units. Worker 2 makes toast, which takes 1 unit, then salad, which takes 2 units. Both workers finish at time 3.worker 1tea (3)worker 2toast (1)salad (2)time0123
Worker 1 makes tea for 3 units of time. Worker 2 makes toast and then salad, so both workers finish at time 3.
Real-life analogyA fixed team of cooks

A restaurant has a fixed team of cooks. It does not hire a new cook for every order. Each new order goes to whichever cook is free.

In real life: The same cooks come in every shift
In JavaScript: A pool starts a fixed number of workers once
In real life: A free cook takes the next order
In JavaScript: A free worker takes the next job
In real life: When all cooks are busy, the order waits
In JavaScript: When all workers are busy, the job waits in line
In real life: Each order is made by one cook
In JavaScript: Each job is done by one worker

How many workers should a pool have? Find out by testing, not by guessing:

  • With too few workers, jobs wait in line.
  • With too many, the workers fight over the computer's processor and memory.

Also keep page updates on the main thread. Put a limit on how many jobs can wait in line. Cancel a job when nobody needs its result any more.

In a browser, navigator.hardwareConcurrency tells you roughly how many jobs the computer's processor can run at the same time. It is only a hint, not the number of workers you must start. The browser may also report fewer than the computer really has. In Node 22, os.availableParallelism() is a better first guess than os.cpus().length. Either way, measure your real work before you pick a pool size.

Step through the pool, job by job
Step 0 of 6Ready
Your turn: follow the blue line

Step through the lesson's runPool model. It works out who gets each job. It does not time real browser workers.

Running in
  1. script
Next: line 1
  { id: "tea", duration: 3 },  { id: "toast", duration: 1 },  { id: "salad", duration: 2 },]; console.log(runPool(tasks, 2));
CallStoreChangeResultBlue line: runs next. Ran: already executed.
Recent returnsNothing yet. Start with the blue line.
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.

Choose a pool size

The model below has four food jobs that do not depend on each other. You change only one thing: the number of workers. Each job goes to the worker that will be free soonest. Then the model shows when each job ends. Reset puts the number back to two workers.

Try each number and watch the total time:

  • With one worker, the jobs run one after another.
  • With two workers, two jobs can run at the same time.
  • At some point, more workers stop helping. Here the tea job alone takes 3 units, so no number of workers can finish before time 3. That is why a pool is a fixed team and a waiting line, not a machine that makes new workers without end.
Playground: change the number of workers
The four jobsJavaScript
const tasks = [  { id: "tea", duration: 3 },  { id: "toast", duration: 1 },  { id: "salad", duration: 2 },  { id: "juice", duration: 2 },]; console.log(runPool(tasks, workers));
When each job endstotal 5
teaworker 1

Ends at time 3.

toastworker 2

Ends at time 1.

saladworker 2

Ends at time 3.

juiceworker 1

Ends at time 5.

Try it yourself

With 2 workers, all the jobs are done at time 5. Each job goes to the worker that will be free soonest.

The job lengths are made-up numbers. This model works out who gets each job; it does not time real browser workers.

In real code, you don't know in advance how long a job will take. The same rule still works: when a worker replies, mark it as free and send it the next job in line. Also plan for these:

  • Cancel a job that nobody needs any more.
  • Decide when to try a failed job again.
  • Make the code that adds jobs slow down when the line is full, so old jobs don't fill up memory.

Call a worker like a function

Messages can carry anything. But when a page sends many kinds of requests, one big onmessage function gets messy. A remote procedure call (RPC) hides the messages behind a normal-looking function. You write await callWorker(worker, "double", [21]) and get the answer back.

Inside, it still uses postMessage. The helper does two extra things:

  • It gives each request a new ID number.
  • It remembers which promise is waiting for each ID, so it can finish that promise when the reply comes.
A small RPC helper that returns promisesJavaScript
let nextId = 1;const pending = new Map(); function callWorker(worker, method, args) {  const id = nextId++;  return new Promise((resolve, reject) => {    pending.set(id, { resolve, reject });    worker.postMessage({ id, method, args });  });} worker.onmessage = (event) => {  const { id, result, error } = event.data;  const call = pending.get(id);  pending.delete(id);  error ? call.reject(new Error(error)) : call.resolve(result);};

Line 1 keeps the next ID number. Line 2 makes a Map called pending. A Map stores pairs: here, each ID is paired with the two functions that finish its promise, resolve (success) and reject (failure).

Lines 4 to 10 are callWorker. It takes a new ID, makes a promise, saves that promise's resolve and reject under the ID, and sends the request. The caller gets the promise right away, before any reply.

Lines 12 to 17 run when a reply arrives:

  1. Line 13 takes the ID, result, and error out of the reply.
  2. Line 14 looks up the saved functions for that ID.
  3. Line 15 removes that ID from the Map, so finished calls don't pile up.
  4. Line 16 calls reject if there is an error, or resolve with the result if there isn't.
Real-life analogyYour token number at a fast-food counter

At a fast-food counter like McDonald's, every order gets a token number. When your number shows on the screen, you take your food, even if people who ordered after you got theirs first.

In real life: You get a token number when you order
In JavaScript: Each request gets its own ID
In real life: Orders are ready in a different order
In JavaScript: Replies can come back in a different order
In real life: The screen shows one token number
In JavaScript: A reply names one ID
In real life: Only the person with that token takes the food
In JavaScript: Only the call with that ID gets the result
Step through one RPC call and its reply
Step 0 of 7Ready
Your turn: follow the blue line

Step through the RPC helper's IDs and promises. The worker itself is not shown here.

Running in
  1. script
Next: line 1
const pending = new Map(); function callWorker(worker, method, args) {  const id = nextId++;  return new Promise((resolve, reject) => {    pending.set(id, { resolve, reject });    worker.postMessage({ id, method, args });  });} worker.onmessage = (event) => {  const { id, result, error } = event.data;  const call = pending.get(id);  pending.delete(id);  error ? call.reject(new Error(error)) : call.resolve(result);};
CallStoreChangeResultBlue line: runs next. Ran: already executed.
Recent returnsNothing yet. Start with the blue line.
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.

Errors come back as replies too

A job in a worker can fail. For example:

  • The request asks for a method the worker doesn't know.
  • The input is wrong, like dividing by zero.
  • The worker's own code throws an error.

Never leave the caller's promise waiting forever. Send a reply with the same ID and an error field. Then the main thread rejects the promise saved under that ID.

One reply with a result, one with an errorJavaScript
worker.postMessage({ id: 1, method: "double", args: [21] });
worker.postMessage({ id: 2, method: "divide", args: [0] });

// Worker replies later:
// { id: 2, error: "cannot divide by zero" }
// { id: 1, result: 42 }

This code only shows the shape of the messages. You can't run it with the worker.js above, because that worker only knows double. Imagine a worker that also knows divide. It could send the error for ID 2 before the result for ID 1. The pending Map still sends each reply to its own caller. Giving each reply to "the newest call" instead would be a bug.

Two replies from the worker. The first reply to arrive has id 2 and an error, and it goes to call 2, which rejects. The second reply has id 1 and result 42, and it goes to call 1, which gets 42.1st replyid: 2errorcall 2 rejects2nd replyid: 1result 42call 1 gets 42
The reply for request 2 comes back first. Each reply carries its ID, so the error goes to call 2 and 42 goes to call 1.

Sometimes a caller should stop waiting, for example after a timeout or when the user presses Cancel. An AbortSignal is a built-in object for saying "stop now". Add one only if your app needs to cancel. When a call is cancelled, reject its promise and remove its ID from the Map. The worker may still finish the job. If its reply arrives later, ignore it, because nobody is waiting for that ID any more.

Keep the messages small

Use a few plain message shapes: { id, method, args } for a request, and { id, result } or { id, error } for a reply. In the worker, check each method name and its inputs before you use them, just as a server checks what a web request sends it.

Move data instead of copying it

Normally postMessage sends a copy of your data (the structured clone from the start of the lesson). For a small settings object, that is fine. But copying a big picture or sound file takes time.

Some objects can be moved instead. They are called transferable objects. One of them is an ArrayBuffer, a block of raw bytes. To move it, you name it in a transfer list, an extra array you pass to postMessage.

Move an ArrayBuffer to a workerPop out in the code editor (opens in a new tab)JavaScript
const worker = new Worker("worker.js");const buffer = new ArrayBuffer(8);const bytes = new Uint8Array(buffer);bytes[0] = 42; worker.postMessage({ buffer }, [buffer]);console.log(buffer.byteLength);

Line 1 starts a worker with the lesson's worker.js file. Line 2 makes a buffer of 8 bytes. Line 3 makes a Uint8Array, a view that lets you read and write the buffer one byte at a time. Line 4 puts 42 in the first byte. Line 6 sends the buffer and also lists it in the transfer list, [buffer]. The 8 bytes move to the worker. They are not copied.

Line 7 prints 0. After the move, the page's buffer is detached: it is empty and has no bytes left. Nothing was lost. The bytes now belong to the worker. This stops two sides from both thinking they can change the same bytes. Think of a transfer as saying "I am done with this buffer here."

Line 6, postMessage with buffer in the transfer list. The 8 bytes move from the page's buffer box to the worker window. The page's buffer box is now empty, and its byteLength is 0.postMessage({ buffer }, [buffer])main.jsbufferbyteLength: 0moved, not copiedworker8 bytes
Line 6 moves the 8 bytes to the worker instead of copying them. The page's buffer is now empty, so line 7 prints 0.

Pick the way to send data by asking who needs it next:

  • If the page does not need the bytes any more, transfer them.
  • If both sides need their own data, copy it.
  • If both sides must use the same bytes at the same time, and can take turns safely, use shared memory.

These are three different choices about who owns the data. They are not three ways to make the same thing faster.

Copy, move, or share

Before you pick a tool, ask who will own the data. Three questions help:

  • Does the worker need its own copy?
  • Is the page done with the bytes, so it can give them away?
  • Do several workers really need to read and write the same bytes?

The answers pick copy, move, or share better than the size of the data does. The table lists each tool. Read each row as: the tool, what it does, and the rule to remember.

Three ways to send data, plus RPC
ToolWhat it doesRule to remember
Structured clone (copy)Sends a copy of the data with the message.The page and the worker each have their own data.
Transfer (move)Moves a buffer or a message port to the worker.The page's ArrayBuffer is left empty.
SharedArrayBuffer (share)Lets several workers use the same bytes.Use Atomics to read and write the bytes safely.
RPCAdds an ID and a promise to each message.Replies can come back in any order and still reach the right call.
Three ways to give data to a worker. Clone: the page and the worker each have their own copy. Transfer: the page is empty and the worker has the data. Share: the page and the worker both use one shared piece of data, which needs Atomics.clonedatacopypageworkertwo copiestransferemptydatapageworkerone ownershareshared bytespageworkerneeds Atomics
A clone gives each side its own copy. A transfer moves the data to one new owner, and sharing lets both sides use the same bytes.

A SharedArrayBuffer is a block of bytes that several workers can use at once. No message has to copy it. That makes it strong, but also harder to use: two workers can read and write the same byte at the same moment. The memory model lesson explains why that goes wrong. Atomics is a built-in object whose methods read and write shared bytes safely, one worker at a time.

Copy, move, or share?
  • A small settings object sent to a worker
  • A big photo ArrayBuffer sent once
  • A MessagePort handed to another worker
  • A number counter that several workers change
  • A small final result object
  • Memory used by Wasm code in several workers
  • A piece of sound sent to one worker to compress
Try it yourself
0 of 7 correct

Put each case in the group that fits it best. Then read why.

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

A real photo editor might use all three. It could copy small settings to a worker. It could move a big image buffer to a filter worker, and get a moved result buffer back. It would use shared memory only when testing shows that workers must use the same bytes at once, and then use Atomics with care.

WebAssembly threads

WebAssembly (Wasm) is a fast, low-level code format that browsers can run next to JavaScript. Programs written in languages like C or Rust can be turned into Wasm. A Wasm program keeps its data in a block of bytes called its memory.

Wasm threads let several workers run Wasm code on one shared memory. Two things make this work:

  • A shared WebAssembly.Memory keeps its bytes in a SharedArrayBuffer, so every worker sees the same bytes.
  • Wasm has its own atomic instructions. Like JavaScript's Atomics, they read and write shared bytes safely.
Make shared WebAssembly memoryJavaScript
const memory = new WebAssembly.Memory({  initial: 1,  maximum: 2,  shared: true,}); console.log(memory.buffer instanceof SharedArrayBuffer);

Lines 1 to 5 make the memory. Wasm memory is measured in pages of 64 KB each:

  • initial: 1 starts it at 1 page.
  • maximum: 2 lets it grow to at most 2 pages. Shared memory must have a maximum.
  • shared: true makes it shared.

Line 7 prints true where the browser allows shared memory, because the memory's bytes are a SharedArrayBuffer. The page needs special security settings for that (see the box below). The lesson editor doesn't have them, so you can't run this code here.

Tools that turn C or Rust into Wasm use workers to run that code on the shared memory. This helps when you move heavy number work from those languages to the web. You still have to split the work, stop two workers from writing the same bytes at the same moment, and collect the results yourself.

The page must be cross-origin isolated

Browsers only give a page SharedArrayBuffer when the page is cross-origin isolated. That means the server sends two security headers, called COOP and COEP, with the page. They stop other sites' pages and files from mixing with yours. Before you offer a shared-memory feature, check crossOriginIsolated. If it is false, use normal messages instead.

This section follows MDN's guide to WebAssembly threads: shared Wasm memory keeps its bytes in a SharedArrayBuffer, and atomic instructions keep shared reads and writes safe. The SharedArrayBuffer & Atomics lesson goes deeper.

A real worker design

Imagine a page where people upload photos. Here is how the work could be split:

  • The main thread shows progress and has a Cancel button.
  • A fixed pool of workers works on parts of the image called tiles. Each tile is a separate job.
  • Each request has an ID. Each finished tile comes back with that ID and a buffer of new pixels.
  • The page moves the original bytes to a worker only after it has finished reading them.
What one tile request looks likeJavaScript
const request = {
  id: 12,
  method: "filterTile",
  args: [{ tile: 3, brightness: 10 }],
};

worker.postMessage(request);
// Later: { id: 12, result: { tile: 3, pixels: outputBuffer } }

This code only shows the plan. It needs a worker that knows filterTile, not the double worker from before. Line 2 gives the request its ID. Line 3 names the method. Line 4 holds the inputs as plain data that can be copied into a message. Line 7 sends it. The worker would reply with ID 12, so the page knows to put tile 3 in the right place.

A few simple rules stop most bugs:

  • Put a version number in your messages. The browser may keep an old copy of the page or worker file, so the two can come from different releases.
  • Limit how many jobs can be waiting at once.
  • Remove a request from the pending Map after it succeeds or fails.
  • If you may try a job again, make sure doing it twice gives the same result as doing it once.

These rules prevent more bugs than clever scheduling does.

Common mistakes

  • “Workers make every job faster.” For a small job, starting the worker, copying the message, and keeping track of replies can take longer than the job.
  • “A transfer is a copy.” A transfer moves the bytes. The page's ArrayBuffer is left empty.
  • “Replies come back in the order I sent the requests.” Separate jobs can finish in any order, so match each reply by its ID.
  • “Shared memory needs no messages.” You still need rules for who writes when, safe reads and writes with Atomics, and a way to know when the work is ready.
  • “Wasm threads get around browser security.” Shared memory only works on a cross-origin isolated page, which needs the right headers from the server.

The table below puts similar ideas side by side. Read each row as: the idea, what it really means, and what it does not mean.

What each idea means, and what it doesn't
IdeaWhat it meansWhat it does not mean
More workersCan help with big jobs that don't depend on each other.Always make work faster.
TransferMoves the bytes to a new owner.Makes two separate copies.
RPC IDMatches one reply to one waiting promise.Tells the worker which processor to use.
Wasm threadsUse shared memory and atomic instructions.Make safe reads and writes happen on their own.

A good habit is to answer three questions before you write any worker code:

  • Who owns each input?
  • Which message says that a job is finished?
  • What happens on an error or a cancel?

Once those are clear, choosing a pool size or whether to transfer is just a question of speed.

Practice exercises

Exercise 1 · Warm-upGuess when the pool finishes

Read the code. What total time does it print for the two jobs?

Starter codePop out in the code editor (opens in a new tab)JavaScript
const tasks = [{ id: "tea", duration: 3 }, { id: "toast", duration: 1 }];
console.log(runPool(tasks, 2).totalTime);

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

    Exercise 2 · Warm-upFind the reply for ID 1

    What does this code print?

    Starter codePop out in the code editor (opens in a new tab)JavaScript
    const replies = new Map([[2, "second"], [1, "first"]]);
    console.log(replies.get(1));

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

      Exercise 3 · PracticeRead the size of a buffer

      Nothing has been sent yet. What does this new buffer print?

      Starter codePop out in the code editor (opens in a new tab)JavaScript
      const buffer = new ArrayBuffer(4);
      console.log(buffer.byteLength);

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

        Exercise 4 · PracticeFinish an RPC request

        Replies can come back in any order. Which field must every RPC request have, so each reply reaches the right promise?

        Starter codePop out in the code editor (opens in a new tab)JavaScript
        worker.postMessage({
          // add the field that matches the reply
          method: "double",
          args: [21],
        });

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

          Exercise 5 · ChallengeUse a transfer on a photo site

          Your photo site sends an image's pixels to one filter worker. The page doesn't need its own copy any more. What should it transfer?

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

            Exercise 6 · ChallengeKeep shared Wasm memory safe

            Which built-in object lets several workers read and write shared WebAssembly memory safely?

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

              Check your understanding

              Each question asks one of three things: can the jobs run on their own, which ID does a reply belong to, and is the data copied, moved, or shared?

              Parallel patterns quiz · 7 questionsScore: first tries count
              1. Question 1 of 7Why use a worker pool with a fixed number of workers?

                Choose an answer to see the explanation.

              2. Question 2 of 7Which reply does this code find?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                const replies = new Map([[2, "second"], [1, "first"]]);
                console.log(replies.get(1));

                Choose an answer to see the explanation.

              3. Question 3 of 7What does the ID in an RPC request do?

                Choose an answer to see the explanation.

              4. Question 4 of 7After you transfer an ArrayBuffer, what happens to the page's buffer?

                Choose an answer to see the explanation.

              5. Question 5 of 7When do WebAssembly threads need a safe way to read and write?

                Choose an answer to see the explanation.

              6. Question 6 of 7Which job is a good fit for a worker pool?

                Choose an answer to see the explanation.

              7. Question 7 of 7How many bytes does this buffer have?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                const buffer = new ArrayBuffer(4);
                console.log(buffer.byteLength);

                Choose an answer to see the explanation.

              Key takeaways

              • A worker pool is a fixed team of workers. It reuses them for many separate jobs, instead of starting a new worker for each job.
              • RPC hides messages behind a function that returns a promise. Each request has an ID, and each reply or error carries the same ID back to its call.
              • A transfer moves bytes instead of copying them. After the move, the page's ArrayBuffer is empty.
              • Shared memory is a different choice. It needs Atomics, clear rules for who writes when, and a cross-origin isolated page.
              • Wasm threads let several workers run Wasm code on one shared memory, using atomic instructions to stay safe.
              • Measure before you add workers. Sending messages and moving data both take time.

              Remember the one-liner.
              Parallel code works best when the jobs, the messages, and who owns the data are all clear.

              Coming next: Embedding V8. V8 is the JavaScript engine in Chrome and Node. You'll see how a program runs V8 inside itself, using isolates, contexts, and handles.

              CompleteFrontend Clear concepts. Working examples.