cf.completefrontendCode editorOpen lab
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
    Schedule bounded workExplain why a pool reuses a fixed number of workers and predict its modeled finish time.
  • 02
    Build a message protocolMatch concurrent RPC replies and errors to the promises that requested them.
  • 03
    Choose 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.

Definition

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.

A tiny pool scheduling modelJavaScript
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.

Real-life analogyA fixed team of cooks

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.

Step through a pool assignment
Step 0 of 6Ready
Your turn: follow the blue line

Replay instrumented pool scheduling. It runs the lesson's runPool model rather than measuring browser threads.

Running in
  1. script
Next: line 1
Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
  { id: "tea", duration: 3 },  { id: "toast", duration: 1 },  { id: "salad", duration: 2 },]; console.log(runPool(tasks, 2));
CallStoreChangeResultRun = next line. 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 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.

Playground: change the pool size
Pool inputJavaScript
const tasks = [  { id: "tea", duration: 3 },  { id: "toast", duration: 1 },  { id: "salad", duration: 2 },]; console.log(runPool(tasks, 2));
Modeled scheduletotal 5
teaworker 1

Finishes at modeled time 3.

toastworker 2

Finishes at modeled time 1.

saladworker 2

Finishes at modeled time 3.

juiceworker 1

Finishes at modeled time 5.

Try it yourself

With 2 workers, the model finishes at time 5. Each task goes to the worker whose modeled finish time is smallest.

This deterministic teaching model schedules supplied durations. It does not claim these are browser-worker timings.

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.

A small promise-based RPC helperJavaScript
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.

Real-life analogyYour McDonald's token number

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.

Step through an RPC reply
Step 0 of 7Ready
Your turn: follow the blue line

Replay instrumented RPC bookkeeping. It shows IDs and promises, not a paused browser worker.

Running in
  1. script
Next: line 1
Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
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);};
CallStoreChangeResultRun = next line. 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.

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.

One success and one error replyPop out in the code editor (opens in a new tab)JavaScript
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.

Keep the protocol small

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.

Transfer an ArrayBufferPop out in the code editor (opens in a new tab)JavaScript
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.

Move, copy, or share

Ask an ownership question before choosing an API. Does the worker need its own snapshot? Does the main thread give up the bytes? Or do multiple workers truly need to read and write one region? The answer picks clone, transfer, or shared memory more reliably than guessing from data size alone.

Choose a data path
ToolWhat happensMain rule
Structured cloneCopies ordinary data for a messageSender and receiver each have data
TransferableMoves ownership of a buffer or portSender's ArrayBuffer is detached
SharedArrayBufferLets workers view the same bytesUse Atomics to coordinate access
RPCAdds request IDs and promises over messagesReplies can arrive out of order safely

Shared memory is powerful because no message copies a large shared buffer. It is also harder because reads and writes can overlap. The memory model lesson explains why plain shared reads and writes can race, and why Atomics provide defined operations for coordination.

Copy, move, or share?
  • A small settings object sent to a worker
  • A large photo ArrayBuffer sent once
  • A MessagePort handed to another worker
  • A cross-worker numeric counter
  • A final plain result object
  • Shared memory used by Wasm workers
  • An audio chunk sent to one encoder worker
Try it yourself
0 of 7 correct

Sort each situation by the clearest data path. Then read why that ownership model fits.

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

A practical image editor might clone small settings, transfer a decoded image buffer to a filter worker, and receive a transferred result buffer back. It would reserve shared memory for a proven case that needs concurrent access and careful atomic coordination.

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.

Create shared WebAssembly memoryJavaScript
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.

Cross-origin isolation is required

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.

A clear worker job shapePop out in the code editor (opens in a new tab)JavaScript
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.
Similar ideas, different promises
IdeaIt meansIt does not mean
More workersCan help independent CPU workAlways makes work faster
TransferMoves ownership of a transferableMakes two independent copies
RPC IDMatches one reply to one pending promiseTells the worker which CPU to use
Wasm threadsUse shared memory and atomicsRemove 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

Exercise 1 · Warm-upPredict a pool finish

Read the model and enter the total time for the two tasks.

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-upMatch the request

    Predict the output for the reply map.

    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 a buffer size

      What does the new buffer print before any transfer?

      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 · PracticeComplete an RPC envelope

        What field must every RPC request include so an out-of-order reply reaches the correct 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 · ChallengeApply transfer to an image site

          Your photo site sends a decoded image to one filter worker and gives up its local copy. What should it transfer?

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

            Exercise 6 · ChallengeProtect shared Wasm memory

            What tool coordinates shared WebAssembly memory safely across workers?

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

              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?

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

                Choose an answer to see the explanation.

              2. Question 2 of 7What does the reply-map snippet print?

                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 an RPC request ID do?

                Choose an answer to see the explanation.

              4. Question 4 of 7After transferring an ArrayBuffer, what should the sender expect?

                Choose an answer to see the explanation.

              5. Question 5 of 7When do WebAssembly threads need coordination?

                Choose an answer to see the explanation.

              6. Question 6 of 7Which job is a good pool candidate?

                Choose an answer to see the explanation.

              7. Question 7 of 7What does the ArrayBuffer snippet print?

                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 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.

              CompleteFrontend Clear concepts. Working examples.