Parallel patterns with workers
Learn worker pools, request IDs, transferables, and WebAssembly threads so browser work can move without blocking the interface.
- 01Share out jobs with a poolExplain why a pool reuses a fixed number of workers, and work out when the lesson's pool model finishes.
- 02Match replies with IDsGive each request an ID, so every reply and error reaches the promise that asked for it.
- 03Choose 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.
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:
idis 1. It is a number that names this request.taskis"double". It says which job to do.valueis 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.
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.
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 lesson's runPool model. It works out who gets each job. It does not time real browser workers.
script
{ id: "tea", duration: 3 }, { id: "toast", duration: 1 }, { id: "salad", duration: 2 },]; console.log(runPool(tasks, 2));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.
const tasks = [ { id: "tea", duration: 3 }, { id: "toast", duration: 1 }, { id: "salad", duration: 2 }, { id: "juice", duration: 2 },]; console.log(runPool(tasks, workers));worker 1Ends at time 3.
worker 2Ends at time 1.
worker 2Ends at time 3.
worker 1Ends at time 5.
With 2 workers, all the jobs are done at time 5. Each job goes to the worker that will be free soonest.
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.
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:
- Line 13 takes the ID, result, and error out of the reply.
- Line 14 looks up the saved functions for that ID.
- Line 15 removes that ID from the Map, so finished calls don't pile up.
- Line 16 calls
rejectif there is an error, orresolvewith the result if there isn't.
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 the RPC helper's IDs and promises. The worker itself is not shown here.
script
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);};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.
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.
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.
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.
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."
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.
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.Memorykeeps its bytes in aSharedArrayBuffer, so every worker sees the same bytes. - Wasm has its own atomic instructions. Like JavaScript's
Atomics, they read and write shared bytes safely.
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: 1starts it at 1 page.maximum: 2lets it grow to at most 2 pages. Shared memory must have a maximum.shared: truemakes 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.
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.
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
pendingMap 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
ArrayBufferis 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.
| Idea | What it means | What it does not mean |
|---|---|---|
| More workers | Can help with big jobs that don't depend on each other. | Always make work faster. |
| Transfer | Moves the bytes to a new owner. | Makes two separate copies. |
| RPC ID | Matches one reply to one waiting promise. | Tells the worker which processor to use. |
| Wasm threads | Use 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
Read the code. What total time does it print for the two jobs?
const tasks = [{ id: "tea", duration: 3 }, { id: "toast", duration: 1 }];
console.log(runPool(tasks, 2).totalTime);It prints 3. Each worker takes one job, and both start at the same time. Tea ends last, at time 3.
What does this code print?
const replies = new Map([[2, "second"], [1, "first"]]);
console.log(replies.get(1));It prints first. Key 1 is paired with first, even though key 2 was added before it.
Nothing has been sent yet. What does this new buffer print?
const buffer = new ArrayBuffer(4);
console.log(buffer.byteLength);It prints 4. Only a transfer would empty the buffer, and this code never transfers it.
Replies can come back in any order. Which field must every RPC request have, so each reply reaches the right promise?
worker.postMessage({
// add the field that matches the reply
method: "double",
args: [21],
});worker.postMessage({ id: 7, method: "double", args: [21] });Each request needs its own id. The reply sends that ID back, so the helper finishes the right waiting promise.
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?
Transfer the ArrayBuffer that holds the image's pixels. The worker becomes its owner, and when the filter is done it can move a new buffer back.
Which built-in object lets several workers read and write shared WebAssembly memory safely?
Use Atomics, plus clear rules for which worker writes when. Shared memory changes who can use the bytes. It does not stop two workers from clashing on its own.
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?
Question 1 of 7Why use a worker pool with a fixed number of workers?
Choose an answer to see the explanation.
Question 2 of 7Which reply does this code find?
Read the code, then predictconst replies = new Map([[2, "second"], [1, "first"]]); console.log(replies.get(1));Choose an answer to see the explanation.
Question 3 of 7What does the ID in an RPC request do?
Choose an answer to see the explanation.
Question 4 of 7After you transfer an ArrayBuffer, what happens to the page's buffer?
Choose an answer to see the explanation.
Question 5 of 7When do WebAssembly threads need a safe way to read and write?
Choose an answer to see the explanation.
Question 6 of 7Which job is a good fit for a worker pool?
Choose an answer to see the explanation.
Question 7 of 7How many bytes does this buffer have?
Read the code, then predictconst 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
ArrayBufferis 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.