Parallel patterns with workers
Learn worker pools, request IDs, transferables, and WebAssembly threads so browser work can move without blocking the interface.
- 01Schedule bounded workExplain why a pool reuses a fixed number of workers and predict its modeled finish time.
- 02Build a message protocolMatch concurrent RPC replies and errors to the promises that requested them.
- 03Choose data movementChoose cloning, transfer, or shared memory and explain the requirements of Wasm threads.
Parallel work needs a plan
A web page has one main thread that responds to taps, updates the DOM, and paints the next frame. A worker is another place to run JavaScript without blocking that main thread. It is useful when work is independent and large enough that sending it away is worth the setup and messaging cost.
A parallel pattern is a repeatable plan for dividing independent work, sending inputs to workers, and joining their results. A good pattern also says who owns the data and how errors return.
This lesson uses four building blocks. A worker pool reuses a fixed number of workers. Remote procedure call (RPC) makes a message look like a promise-returning function call. A transferable moves ownership of data without copying bytes. WebAssembly (Wasm) threads use shared memory and atomic operations for a more specialized kind of parallel work.
Workers do not share the page DOM. They usually exchange structured-cloned values through postMessage. That separation is useful: it forces a clear boundary between UI state on the main thread and CPU-heavy work elsewhere. It also means a worker is not a free speed button.
The previous lesson, The memory model, explains why shared writes need ordering rules. For worker basics, revisit Web workers and messaging and cloning.
A first worker message
Start with one request and one reply. The main script sends an object whose id is 1, asks for the double task, and supplies 21. 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 registers the worker's message handler. Line 2 reads the request object. Lines 3 through 5 choose the small task and reply with its ID. Keeping the ID in the reply is harmless for one request and essential once several requests overlap.
const worker = new Worker("worker.js"); worker.onmessage = (event) => { console.log(event.data);}; worker.postMessage({ id: 1, task: "double", value: 21 });Line 1 starts the worker file. Lines 3 through 5 print a reply when it arrives. Line 7 sends the request. The console logs { id: 1, result: 42 }. The editor can run this because the lesson provides the displayed worker.js file.
Notice what did not happen: the main script did not wait on line 7. It posts a message and can keep rendering or responding to input. The reply is a later event, which is why a protocol needs a way to identify the request that caused it.
Worker pools
Starting a worker has a cost: the browser creates an execution environment, loads code, and prepares a message channel. Starting one for every small job can cost more than the job itself. A pool starts a small fixed team and gives the next independent task to the first free worker.
const tasks = [ { id: "tea", duration: 3 }, { id: "toast", duration: 1 }, { id: "salad", duration: 2 },]; console.log(runPool(tasks, 2).totalTime);Lines 1 through 5 make three jobs with modeled durations. Line 7 gives them to two reusable workers. The model prints 3: one worker does tea in three units while the other does toast then salad in one plus two units. These durations are inputs, not measurements of a real CPU.
A restaurant keeps a fixed team of cooks. New orders go to whichever cook is free, instead of hiring a new cook for every order.
- In real life: A fixed team starts each shift
- In JavaScript: A pool starts a fixed number of workers
- In real life: A free cook takes the next order
- In JavaScript: A free worker takes the next task
- In real life: A busy cook finishes first
- In JavaScript: The pool waits for capacity
- In real life: One order has one cook
- In JavaScript: One task has one assigned worker
Where the analogy stops: Real workers have startup, messaging, and CPU costs. A pool cannot make dependent tasks independent.
Choose pool size from evidence, not a magic number. Too few workers leave CPU work waiting. Too many workers compete for CPU time, memory, and cache space. Keep UI work on the main thread, bound queue growth, and cancel work whose result is no longer useful.
Replay instrumented pool scheduling. It runs the lesson's runPool model rather than measuring browser threads.
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 independent food-preparation tasks. Change only the number of workers. It assigns each next task to the worker with the smallest finish time, then shows the resulting schedule. Reset restores two workers.
One worker does all work in sequence. Two workers often overlap independent jobs. More workers can stop helping once there are fewer useful tasks than workers, which is why a pool is a bounded queue plus a fixed team rather than an unlimited worker factory.
const tasks = [ { id: "tea", duration: 3 }, { id: "toast", duration: 1 }, { id: "salad", duration: 2 },]; console.log(runPool(tasks, 2));worker 1Finishes at modeled time 3.
worker 2Finishes at modeled time 1.
worker 2Finishes at modeled time 3.
worker 1Finishes at modeled time 5.
With 2 workers, the model finishes at time 5. Each task goes to the worker whose modeled finish time is smallest.
In production, task duration is not known in advance. The same basic policy still works: when a worker replies, mark it free and send the next queued task. Include cancellation, retry rules, and backpressure so a producer cannot fill memory with stale queued jobs.
RPC over postMessage
Messages are flexible, but raw event handlers get noisy when a page has many request types. RPC makes a worker request feel like await callWorker(worker, "double", [21]). Underneath, it still uses postMessage; the helper adds an ID and remembers the promise callbacks that are waiting for that ID.
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 supplies a new number for every request. Line 2 makes a map from IDs to promise callbacks. Lines 4 through 9 create a promise, store its callbacks under the new ID, and post the request. The caller receives that promise immediately.
The handler on lines 12 through 17 receives a reply. It looks up the matching callbacks, removes that pending entry, and either resolves the promise with result or rejects it with error. Deleting first prevents the map from retaining finished calls.
At McDonald's, every request gets a number. When a number appears, the right customer gets the food, even when later orders finish first.
- In real life: You receive a token number
- In JavaScript: A request receives a unique ID
- In real life: Orders finish in a different order
- In JavaScript: Replies can arrive out of order
- In real life: The screen shows one token
- In JavaScript: The reply names one pending promise
- In real life: You collect your own food
- In JavaScript: Only the matching call resolves
Where the analogy stops: A worker protocol also carries methods, arguments, errors, cancellation, and version rules.
Replay instrumented RPC bookkeeping. It shows IDs and promises, not a paused browser worker.
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);};Replies and errors are both protocol messages
A worker can fail because a method name is unknown, an input is invalid, or its own code throws. Do not leave the caller's promise pending forever. Reply with the same ID and an error field, then reject the matching promise on the main thread.
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 }Lines 1 and 2 start two independent calls. The comment shows a legal reply order: the error for ID 2 can arrive before the success for ID 1. The map makes each outcome reach its own caller. This is why matching by "the latest reply" is a bug.
Add a timeout or an AbortSignal only when your product needs cancellation. A timeout should reject and remove its map entry. It should not assume that the worker stopped calculating; a later reply may need to be ignored because nobody is waiting for that ID anymore.
Use plain message shapes such as { id, method, args } and { id, result } or { id, error }. Validate methods and data at the worker boundary, just as you validate a network API.
Transferables move data fast
Structured cloning makes a separate copy of ordinary message data. That is simple for a small settings object, but copying a large image or audio buffer can be expensive. A transferable lets you move ownership of selected objects, including an ArrayBuffer, by putting it in the transfer list.
const buffer = new ArrayBuffer(8);const bytes = new Uint8Array(buffer);bytes[0] = 42; worker.postMessage({ buffer }, [buffer]);console.log(buffer.byteLength);Line 1 allocates eight bytes. Lines 2 and 3 make a view and write 42 into the first byte. Line 5 sends the buffer and names the same buffer in the transfer list. Ownership moves to the receiving side instead of making another eight-byte copy.
Line 6 logs 0 after a successful transfer because the sender's buffer is detached. That is a feature, not data loss: it prevents two owners from both believing they can change the same ordinary ArrayBuffer. Treat transfer as “I am done with this buffer here.”
Transfer only when the sender truly has no next use for the bytes. If both sides need independent data, clone. If they need one shared memory region and can coordinate access, consider shared memory instead. Those are different ownership choices, not interchangeable speed tricks.
WebAssembly threads
WebAssembly threads are for programs that need several workers to operate on one shared Wasm memory. A shared WebAssembly.Memory exposes a SharedArrayBuffer as its buffer. Wasm atomic memory instructions coordinate access, much like JavaScript Atomics coordinate a SharedArrayBuffer.
const memory = new WebAssembly.Memory({ initial: 1, maximum: 2, shared: true,}); console.log(memory.buffer instanceof SharedArrayBuffer);Lines 1 through 5 make memory with an initial and maximum size and mark it shared. A maximum is required for shared Wasm memory. Line 7 prints true in an environment that supports the feature because the backing buffer is a SharedArrayBuffer. Run this in a cross-origin-isolated page with the required COOP and COEP headers; the lesson editor cannot provide shared memory.
The Wasm threads proposal has two connected parts: shared memories and atomic memory accesses. Compilers and runtimes use workers to execute code against that memory. This is useful for ports of native computation, but it does not remove the need to design work partitions, avoid races, or join results.
Browsers require a cross-origin-isolated page before exposing SharedArrayBuffer in normal web use. Set the required COOP and COEP headers, then check crossOriginIsolated before offering a shared-memory feature. Fall back to messages when isolation is unavailable.
This description follows MDN's WebAssembly threads guide: shared WebAssembly memory is backed by SharedArrayBuffer and atomic operations protect shared access. Keep the details here simple; the SharedArrayBuffer & Atomics lesson goes deeper.
A practical worker design
Imagine a photo-upload page. The main thread shows progress and accepts cancellation. A fixed pool decodes independent image tiles. Each request has an ID, and each completed tile returns an ID plus its output buffer. The page transfers the original bytes 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 } }Line 1 starts one named request. Line 2 gives it a stable ID. Line 3 says what the worker should do, and line 4 contains serializable inputs. Line 7 posts it. The later reply repeats ID 12, so the page can put tile 3 in the right place even if tile 4 finished first.
Keep protocol versions explicit when the main thread and worker can be deployed from different cached files. Limit outstanding work, remove pending requests after success or failure, and make results idempotent where retry is possible. Those ordinary boundaries prevent more bugs than clever scheduling.
Common mistakes
- “Workers make every task faster.” Small work can lose to startup, serialization, and coordination costs.
- “A transfer is a copy.” It moves ownership. The sender's ArrayBuffer becomes detached.
- “Reply order is request order.” Independent jobs may finish in any order, so match IDs.
- “Shared memory needs no messages.” You still need a protocol, synchronization, and a way to know when work is ready.
- “Wasm threads bypass browser security.” Shared memory requires cross-origin isolation and careful deployment headers.
| Idea | It means | It does not mean |
|---|---|---|
| More workers | Can help independent CPU work | Always makes work faster |
| Transfer | Moves ownership of a transferable | Makes two independent copies |
| RPC ID | Matches one reply to one pending promise | Tells the worker which CPU to use |
| Wasm threads | Use shared memory and atomics | Remove the need for synchronization |
The useful habit is to draw the boundary first: who owns each input, which message ends the job, and what happens on error or cancellation. Once those are clear, pool size and transfer choices become focused performance decisions instead of architecture guesses.
Practice exercises
Read the model and enter the total time for the two tasks.
const tasks = [{ id: "tea", duration: 3 }, { id: "toast", duration: 1 }];
console.log(runPool(tasks, 2).totalTime);It prints 3. The two workers can start both independent jobs together, so tea determines the final modeled time.
Predict the output for the reply map.
const replies = new Map([[2, "second"], [1, "first"]]);
console.log(replies.get(1));The answer is first. Key 1 maps to that value even though key 2 was inserted first.
What does the new buffer print before any transfer?
const buffer = new ArrayBuffer(4);
console.log(buffer.byteLength);It prints 4. A transfer would detach the sender later; creating a buffer does not detach it.
What field must every RPC request include so an out-of-order reply reaches the correct 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 a unique id. The reply returns that ID so the client resolves the correct pending promise.
Your photo site sends a decoded image to one filter worker and gives up its local copy. What should it transfer?
Transfer the ArrayBuffer holding the decoded image. The worker becomes the owner and can return another transferred buffer when filtering completes.
What tool coordinates shared WebAssembly memory safely across workers?
Use Atomics and a designed synchronization protocol. Shared memory changes ownership rules; it does not make data-race bugs disappear.
Check your understanding
Use the same questions a production design needs: is work independent, which ID owns the reply, and does data copy, move, or share?
Question 1 of 7Why keep a fixed worker pool?
Choose an answer to see the explanation.
Question 2 of 7What does the reply-map snippet print?
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 an RPC request ID do?
Choose an answer to see the explanation.
Question 4 of 7After transferring an ArrayBuffer, what should the sender expect?
Choose an answer to see the explanation.
Question 5 of 7When do WebAssembly threads need coordination?
Choose an answer to see the explanation.
Question 6 of 7Which job is a good pool candidate?
Choose an answer to see the explanation.
Question 7 of 7What does the ArrayBuffer snippet print?
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 reuses a bounded team for independent work instead of starting a worker per task.
- RPC wraps messages in promises, with an ID that connects every reply or error to one pending call.
- Transferables move ownership without copying; the sender's transferred ArrayBuffer becomes detached.
- Shared memory is a different choice that needs Atomics, a protocol, and cross-origin isolation on the web.
- Wasm threads combine shared WebAssembly memory with atomic accesses across workers.
- Measure before adding workers: coordination and data movement have costs.
Remember the one-liner.
Parallel code works best when tasks, messages, and data ownership are all explicit.
Coming next: Embedding V8, where a runtime hosts an engine with isolates, contexts, and handles.