What you'll learn: By the end of this lesson you will be able to explain what a Web Worker may and may not touch, describe what
postMessagedoes to your data (structured clone) and when a transfer list removes the copy, design the message boundary and a small pool with queueing and cancellation, choose between a worker, a worklet,OffscreenCanvasand chunking, and say honestly what offloading buys: less risk for the main thread, not less total work.
Picture a restaurant where one head chef takes orders, plates dishes and answers the door. A second cook in a back kitchen sounds like a fix, but the two cannot share a counter: they pass paper tickets through a hatch. A short ticket is cheap. A ticket that says "here is the whole pantry inventory" is not, unless you hand over the entire box instead of copying its contents.
A Web Worker is that back kitchen. The Event Loop and the Main Thread listed workers as one remedy for a busy main thread. This lesson covers what the hatch costs, how to design what goes through it, and what to do when there are more tickets than cooks.
The core idea
- A worker is another thread with its own event loop and no DOM. Page and worker share nothing by default. The only link is messages.
- A message is a copy, unless you transfer it. The sender pays to serialise and the receiver to rebuild, each on its own thread. A transfer moves ownership of a buffer instead.
- Offloading is a risk decision. It keeps the main thread available, and it adds messages, start-up and state on two sides. Total work does not shrink.
What a worker is, as the standards describe it
The HTML Standard says workers run scripts in the background, independently of the user interface scripts, so long work need not yield to keep the page responsive. It also says workers are "relatively heavy-weight" and "not intended to be used in large numbers" (launching one per pixel of a four megapixel image would be inappropriate), and expects them to be long-lived, with a high start-up cost and a high per-instance memory cost.
MDN names three kinds. A dedicated worker is reachable only from the script that created it. A shared worker can be reached from several windows, frames or workers through a port. Service workers sit between page and network and are a separate topic.
Inside a worker the global is self, not window, and you cannot touch the DOM or the parent page. You can run any JavaScript, use fetch(), WebSockets and IndexedDB, call setTimeout, and start same-origin sub-workers. MDN says Worker creates real OS-level threads, and that because the communication points are controlled and there is no DOM, it is hard to cause classic concurrency bugs.
A worker has its own event loop. The standard says its task queues only have events, callbacks and networking activity as tasks, and a posted message arrives as a task on the receiver's loop: handled in a later turn, never in the middle of running code.
Illustration 1: a minimal round trip
// main.js (browser code, shown for shape; not run in this lesson)
const worker = new Worker(new URL("./sum-worker.js", import.meta.url), { type: "module" });
worker.onmessage = (event) => console.log("sum:", event.data.sum);
worker.postMessage({ id: 1, numbers: [1, 2, 3, 4] });
// sum-worker.js
self.onmessage = (event) => {
const { id, numbers } = event.data;
self.postMessage({ id, sum: numbers.reduce((a, b) => a + b, 0) });
};
The reply carries the request's id. Keep that habit: the id is how you match replies, ignore stale ones and cancel.
Figure 1. One page, a small pool of workers
How to read this: the queue is code you write on the page side. Each worker handles one message at a time on its own thread, and every result comes back as a message the page handles as a task.
What postMessage does to your data
By default postMessage() runs the data through the structured clone algorithm. MDN: data passed between page and worker is copied, not shared, "except for certain objects that can be explicitly shared".
From MDN's description: primitives except symbol, arrays, Map, Set, Date, ArrayBuffer, typed arrays and plain objects survive, and a map of visited references lets cycles terminate. Functions and DOM nodes throw DataCloneError. The prototype chain is not walked, and getters, setters and property descriptors are not duplicated, so a class instance arrives as a plain object without its methods. Two checks in Node.js v26.12.1 (whose structuredClone is the same kind of algorithm, though not a browser's) agree: cloning { onDone: () => 1 } threw DataCloneError, and a cloned class instance had constructor === Object and no methods.
Transfer instead of copy
postMessage(message, transfer) takes a list of transferable objects. MDN: after a transfer the original "is no longer usable", and an ArrayBuffer's memory is moved in a zero-copy operation. MessagePort, ImageBitmap, OffscreenCanvas and streams are also on MDN's list. A typed array is serialisable but not transferable: transfer its .buffer.
const buf = new ArrayBuffer(8 * 1024 * 1024);
const moved = structuredClone(buf, { transfer: [buf] });
console.log(buf.byteLength, moved.byteLength); // 0 8388608 (printed by Node v26.12.1)
Figure 2. Where the time goes on a round trip
How to read this: the page pays twice, to send and to receive. If those two costs approach the compute in the middle, offloading saved nothing.
How expensive is a message? There is no universal number. Surma measured it in 2019 on devices of that time: even on the slowest, objects up to 100 KiB stayed within a 100 ms response budget, for JavaScript-driven animation he suggested staying under 10 KiB, and transferring an ArrayBuffer was independent of its size. Treat these as an order of magnitude from an older measurement and measure your own payloads on your users' devices.
| Way to send data | What happens | Strength | Watch out for |
|---|---|---|---|
| Clone (default) | Serialise, copy, rebuild | Ordinary data; both sides keep a copy | Cost grows with size, paid by the page on both ends |
| Transfer a buffer | Ownership moves; sender's buffer detached | Cost independent of size | Sender cannot reuse it; you design a binary layout |
SharedArrayBuffer |
Threads operate on the same memory | No message to see updates | MDN flags determinism, security and performance concerns |
| Send a patch | Only what changed | Cost follows the change | You define and test the patch format |
Designing the boundary
Keep it small and typed. Every message has a type, an id and plain data. Write the worker's logic as pure functions (data in, data out) and wrap them thinly, so the logic is testable without a browser. Web.dev also points to Comlink, which lets you use workers without handling postMessage directly; it hides the protocol, not its costs.
Send less. Send what the worker cannot already have (a file, a changed record), not the whole state on every keystroke, and return a summary rather than a million rows the page must rebuild.
Queue on purpose. A pool is a queue in front of a few workers. When jobs arrive faster than workers finish, every new job waits longer. Pick a policy: a bounded queue, newest-wins for interactive work, first-in-first-out for batch. navigator.hardwareConcurrency is a starting point for pool size, to be measured: the standard says it is between 1 and the number of logical processors, is 1 when it cannot be determined, and may be lowered for limits or to reduce fingerprinting. The main thread needs a core too.
Cancel cooperatively, terminate rarely. A running worker cannot be interrupted by a message, which waits for the current task. Check a flag between slices of work, or call worker.terminate(), which MDN says kills the thread immediately, with its state and start-up cost.
Figure 3. Life of one job
How to read this: only a queued job can be cancelled for free. A running job holds a worker until it ends, so keep jobs short or sliced if cancellation matters.
Illustration 2: a realistic example
A report page parses a CSV export of about 40 MB (illustrative). On the main thread that is one long task. With a worker, the page reads the file into an ArrayBuffer, transfers it, and gets back a few kilobytes of aggregates. The shape that makes this a good candidate is large in, small out, no DOM.
Illustration 3: failures and edge cases
- A function in the message throws
DataCloneError. Send a message name instead. - Using a buffer after the transfer. Its
byteLengthis 0 and reading the length does not throw. In Node v26.12.1 aUint8Arrayover a detached buffer threwTypeError,TextDecoder.decodereturned an empty string, and a second transfer threwDataCloneError. - Cloning the world on each event. A 5 MB (illustrative) state posted per keystroke keeps the page busy with serialisation every time. Send patches.
- Too many workers. The standard calls them heavy: dozens cost start-up and memory without throughput beyond the cores.
Beyond dedicated workers: worklets and OffscreenCanvas
MDN calls a Worklet a lightweight version of Web Workers with access to low-level parts of the rendering pipeline, restricted to specific uses and not for arbitrary computation. It lists AudioWorklet, AnimationWorklet, LayoutWorklet and SharedStorageWorklet, and notes paint worklets come from CSS.paintWorklet. You load code with addModule(); dynamic import() is not allowed. Support differs by type, so check MDN's table for the one you need. Use one when the platform asks for it, such as custom audio.
MDN says OffscreenCanvas decouples the canvas from the DOM so rendering can run in a worker, is transferable, and transferControlToOffscreen() hands a visible canvas to a worker. It marks it Baseline widely available since March 2023, with some parts varying. It suits heavy canvas or WebGL drawing, not DOM-bound work.
Figure 4. Picking where the work runs
audio processing?"} E -->|"yes"| F["Worklet"] E -->|"no"| G["Dedicated worker or small pool"] G --> H{"Large input?"} H -->|"yes"| I["Transfer the buffer, return a summary"] H -->|"no"| J["Clone small messages"]
How to read this: the first question is whether the work needs the DOM. If it does, a worker cannot do it, and the answer is the chunking from the previous lesson.
Build it: a model of the message boundary
Goal. See, in a deterministic model with a virtual clock, how clone and transfer change how long the main thread stays busy, and how a pool's queue behaves, with the model's clone semantics checked against the runtime's own structuredClone.
This is a model of the idea, not a browser. There is no real thread, Worker, event loop or timing. The costs are parameters in demo.ts. The only real behaviour compared is Node.js's structuredClone, which is not a browser's.
Setup. The lab is in the course's example workspace (Node.js, captured on 26.12.1, which runs TypeScript directly, and pnpm).
cd examples/frontend
pnpm install
pnpm --filter ./labs/worker-message-model test # 30 tests
cd labs/worker-message-model
node src/demo.ts # prints the output below
Steps.
- Part 1 runs nine cases through the model and
structuredClone. Every line must sayagree; the demo exits non-zero otherwise. - In part 2, the clone column grows with buffer size (1 KiB to 16 MiB) and the transfer column stays flat: the shape MDN and Surma describe, not a measurement.
- In part 3, run A (inline) is one 480 ms block. Run B moves the compute off the page, but cloning 2 MiB each way keeps it busy 164.65 virtual ms, with a longest block of 82.32 (a long task by the 50 ms rule). Run C transfers: 1.61 busy, but one worker still queues jobs (longest wait 331.00). Run D adds workers: longest wait 29.80, essentially start-up.
- Run E shows four workers do not spare the page if every message is cloned.
Output captured from a real run (Node 26.12.1, 2026-11-08):
=== part 1: what postMessage does to a value (model versus this runtime's structuredClone)
1. nested data is copied, not shared
model: copy.list.length after the sender pushes: 3
real: copy.list.length after the sender pushes: 3
agree
2. a cycle stays a cycle
model: copy.self === copy: true
real: copy.self === copy: true
agree
3. a class instance arrives as a plain object
model: prototype is Object.prototype: true; has norm(): false
real: prototype is Object.prototype: true; has norm(): false
agree
4. a function cannot be sent
model: DataCloneError
real: DataCloneError
agree
5. a Map and a Set are copied with their contents
model: map size 1, set size 2
real: map size 1, set size 2
agree
6. sending a buffer by copy leaves the sender's buffer intact
model: sender byteLength afterwards: 4
real: sender byteLength afterwards: 4
agree
7. transferring a buffer detaches the sender's copy
model: sender byteLength afterwards: 0; receiver byteLength: 4
real: sender byteLength afterwards: 0; receiver byteLength: 4
agree
8. a typed array is not itself transferable (transfer its buffer)
model: DataCloneError
real: DataCloneError
agree
9. transferring the same buffer twice fails the second time
model: DataCloneError
real: DataCloneError
agree
=== part 2: cost of one side of one message (virtual ms; model parameters, not measurements)
cost model: 0.1 ms per message + 0.01 ms per cloned model KiB + 0.1 ms per transferred buffer
buffer size clone transfer
1 KiB 0.11 0.20
100 KiB 1.10 0.20
2 MiB 20.58 0.20
16 MiB 163.94 0.20
=== part 3: four 2 MiB image jobs, 120 ms of compute each, submitted at t=0, 20, 40, 60 (virtual ms)
cost model as in part 2; a worker needs 30 ms to start; "long" means a main-thread block over 50 ms
--- A. inline on the main thread
main thread busy 480.00 longest block 480.00 long tasks 1
last result at 480.00 mean latency 270.00 longest queue wait 0.00
job submit worker-start worker-end done worker
job1 0.00 0.00 120.00 120.00 main
job2 20.00 120.00 240.00 240.00 main
job3 40.00 240.00 360.00 360.00 main
job4 60.00 360.00 480.00 480.00 main
--- B. 1 worker, data cloned
main thread busy 164.65 longest block 82.32 long tasks 1
last result at 695.23 mean latency 423.48 longest queue wait 431.16
job submit worker-start worker-end done worker
job1 0.00 30.00 191.16 211.74 0
job2 20.00 191.16 352.32 372.90 0
job3 40.00 352.32 513.48 534.06 0
job4 60.00 513.48 674.65 695.23 0
--- C. 1 worker, buffers transferred
main thread busy 1.61 longest block 0.20 long tasks 0
last result at 511.81 mean latency 301.20 longest queue wait 331.00
job submit worker-start worker-end done worker
job1 0.00 30.00 150.40 150.60 0
job2 20.00 150.40 270.80 271.00 0
job3 40.00 270.80 391.20 391.40 0
job4 60.00 391.20 511.61 511.81 0
--- D. 4 workers, buffers transferred
main thread busy 1.61 longest block 0.40 long tasks 0
last result at 180.80 mean latency 130.75 longest queue wait 29.80
job submit worker-start worker-end done worker
job1 0.00 30.00 150.40 150.60 0
job2 20.00 30.00 150.40 150.80 1
job3 40.00 40.20 160.60 160.80 2
job4 60.00 60.20 180.60 180.80 3
--- E. 4 workers, data cloned
main thread busy 164.65 longest block 82.32 long tasks 2
last result at 273.48 mean latency 212.61 longest queue wait 9.42
job submit worker-start worker-end done worker
job1 0.00 30.00 191.16 211.74 0
job2 20.00 41.16 202.32 232.32 1
job3 40.00 61.74 222.90 252.90 2
job4 60.00 82.32 243.49 273.48 3
Reading the table: these numbers come from the cost model above, not from a browser.
They show the shape: cloning keeps the main thread busy in proportion to size, a transfer does not,
and a queue behind too few workers shows up as waiting time, not as main-thread work.
Break it. In src/clone.ts, in cloneModel, delete the last loop (for (const buffer of transfer) buffer.transfer();). A "transferred" buffer is no longer detached: four tests fail and the demo prints DIFFERENT for cases 7 and 9 and exits non-zero. Revert it.
Stretch. Give the receiver its own cost parameter; add cancellation between jobs and show a stale job still finishing.
In practice: a CSV reporting dashboard
This is a realistic scenario, not a case history. It is a composite built from common production patterns. It describes no specific company or product, and every number in it is an illustrative assumption.
The situation
An analytics page lets analysts upload CSV exports up to about 40 MB, parses them and draws three charts. The parse blocks the main thread for seconds, the progress bar stops, and users click twice. Three engineers own the page.
Starting architecture
Figure 5. Everything on the main thread
How to read this: the warning boxes share the thread that also handles clicks and paints the progress bar.
Options considered
| Option | What it fixes | What it costs | Risk |
|---|---|---|---|
| Chunk the parse and yield | No long task, no new infrastructure | Async parser, more total time | Still competes with input |
| One worker, transfer the file | Parse off the main thread; no copy of the input | Worker file, protocol, build setup | A second upload queues behind the first |
| Pool, split the file | Parallel parse | Safe row splitting, merging | Complexity beyond the need |
| Parse on the server | Client does little | Upload, API, storage | Latency, data-privacy review |
The decision
| Field | Decision record |
|---|---|
| Context | Parsing a large CSV on the main thread freezes the page for seconds. |
| Decision | One dedicated worker. Read the file into an ArrayBuffer, transfer it, return aggregates only. Messages carry a jobId; the page ignores replies for an older job. |
| Consequences, good | Page stays responsive; the large input crosses without a copy; the parser is a pure function tested without a browser. |
| Consequences, bad | A worker and protocol to maintain; progress needs explicit messages; stale jobs still occupy the worker. |
| Revisit when | Concurrent uploads become common (small pool), or aggregates grow until the reply costs the page noticeably. |
Rollout and verification
- Test the parser as a pure function with fixtures, including a malformed row.
- Test the protocol with a fake worker that replies out of order: a stale
jobIdmust be ignored. - Check in a real browser with CPU throttling: record a profile and confirm no long task during the parse.
- Ship behind a flag and compare abandonment of the upload page.
The 2 a.m. failure
Support reports that "Export again" on the same upload gives blank charts and no error. The retry reuses the ArrayBuffer transferred to the worker on the first attempt. After a transfer the sender's buffer is detached with byteLength 0, so the second parse sees an empty file. How that surfaces depends on how the parser reads it: in Node v26.12.1 a typed-array view over it throws, while TextDecoder.decode returns an empty string. In this composite the parser decodes with TextDecoder and treats empty input as zero rows, so nothing fails.
First three checks: (1) log buffer.byteLength before each postMessage; (2) search for code that keeps a buffer after transferring it; (3) confirm the worker's received length equals the file size. Mitigation: re-read the File on each attempt. Follow-up: a helper that takes a File, reads it and transfers a fresh buffer every time, plus a test that sends the same file twice.
Architect's notes
- Move work that is large in and small out. A transfer makes the boundary cheap and the reply cheap to clone.
- Judge by main-thread behaviour. Web.dev says off-main-thread architecture is about reducing risk and does not by itself make an app faster. Watch long tasks and interaction latency.
- Keep logic pure and the wrapper thin.
- Version the protocol. The page bundle and worker script can be cached separately.
- Own the queue. Decide bounds, ordering and cancellation before the first incident.
Common mistakes
- Treating a worker as free parallelism. It adds messages, start-up and two-sided state.
- Posting large objects on every event.
- Reusing a transferred buffer.
- Sending functions or class instances.
- Expecting a message to interrupt a running job.
- Launching many workers.
- Using a worker for DOM work.
Key takeaways
- A worker is a thread with its own event loop and no DOM, reached only by messages.
- A message is structured-cloned by default; functions throw
DataCloneError. - A transfer list moves an
ArrayBuffer; the sender's copy is detached. - The page pays for sending requests and receiving replies, so large replies can eat the gain.
- Design the boundary: typed messages with ids, small payloads, patches, a queue policy, cooperative cancellation.
- Worklets serve specific rendering and audio hooks;
OffscreenCanvasmoves canvas drawing; DOM work stays on the main thread. - Offloading reduces main-thread risk, not total work.
Practice exercises
Exercise 1 — Starter (30 min): In Node, structuredClone an object holding a Map, a Date, a class instance and a cycle, then one holding a function. Write down what changed and what threw.
Exercise 2 — Intermediate (2 hours): In the lab, set the cost parameters so a transfer costs as much as cloning 100 KiB. Predict which of runs B to E change ranking, then check.
Exercise 3 — Advanced (half day): Build a page whose worker sorts 1 million numbers, once cloned and once as a transferred Float64Array buffer, and compare main-thread time in a throttled profile.
Related reading
In this course:
- The Event Loop and the Main Thread — long tasks, yielding and chunking.
- How a Browser Actually Works — processes and threads inside a browser.
- Loading Performance and Core Web Vitals — measuring responsiveness in the field.
Official documentation
- HTML Standard: Web workers, the introduction, the worker event loop and
hardwareConcurrency. - MDN: Using Web Workers
- MDN: The structured clone algorithm and Transferable objects
- MDN: Worklet and OffscreenCanvas
- web.dev: Use web workers to run JavaScript off the browser's main thread and Surma: Is postMessage slow?