Web workers for performance
Move heavy work off the main thread into a web worker, measure what each message costs, transfer big data instead of copying it, and draw with OffscreenCanvas.
- 01Move the right work to a workerTell work that can leave the main thread from work that needs the page, and measure what moving it saves.
- 02Keep messages cheapKnow what copying a message costs on each side, and transfer big buffers instead of copying them.
- 03Draw and share off the main threadHand a canvas to a worker with OffscreenCanvas, and know when shared memory is allowed.
Can the reviews be read somewhere else?
Lesson 27.3 left Ravi's tea list in good shape. reviews.js reads this week's 4,000 reviews in pieces of 10 ms. It rates the six teas on Asha's screen first. Her clicks get in between the pieces.
Then Ravi opens the trace. The work is cut up, but all of it is still there. It still runs on the main thread: the one thread that runs the page's JavaScript, answers clicks and draws the screen. Each picture it draws is a frame. While the pieces ran, Chrome drew only 10 frames.
Meera has plans for more, like a chart of the reviews. So Ravi asks a new question. Does this work have to run on the main thread at all? Could it run somewhere else, and leave the main thread free?
It can. A web worker is a second thread that a page starts for its own JavaScript. Lesson 6.1 moved a photo filter into one, and a tap stopped waiting. This lesson moves reviews.js's work into a worker. Here is what changed on Asha's phone:
// Asha's phone, while the reviews were read: each thread's busy time in ms, and the frames drawn.const pages = [ { name: "10 ms pieces", mainThread: 657.6, worker: 0, frames: 10 }, { name: "A worker", mainThread: 91.2, worker: 410.4, frames: 23 },];for (const page of pages) { console.log(`${page.name}: main thread ${page.mainThread} ms, worker ${page.worker} ms, ${page.frames} frames`);}- Line 1 is a comment. Busy times are in milliseconds (ms). A millisecond is a thousandth of a second.
- Lines 3 and 4 hold two versions of the page.
mainThreadis how long the main thread was busy while the reviews were read.workeris how long the worker was busy.framescounts the frames Chrome showed. - Line 7 prints
10 ms pieces: main thread 657.6 ms, worker 0 ms, 10 frames. Then it printsA worker: main thread 91.2 ms, worker 410.4 ms, 23 frames.
The work didn't vanish. It moved: 410.4 ms of it ran in the worker. The main thread's share fell from 657.6 ms to 91.2 ms. And Chrome drew more than twice as many frames. Asha clicked Add on Mint tea while the reviews were read. These are real frames Chrome captured:
With the 10 ms pieces, the frame on her screen was 53.2 ms old when she clicked. Cart: 1 appeared 51.2 ms after her click. With the worker, the frame was 10.8 ms old, and Cart: 1 came after 36.7 ms. Why it matters: a main thread with fewer jobs gets to each frame and each click sooner.
Meera used to bake the shop's scones herself, one batch between customers. Now she orders them from the bakery next door. She writes an order slip, and the bakery bakes in its own oven while she serves. A tray of scones comes back. The bakery can't serve her customers. And each slip and tray takes a little time to carry.
- In real life: Meera at the counter
- In the browser: The main thread
- In real life: The bakery next door, with its own oven
- In the browser: A web worker, with a thread of its own
- In real life: An order slip there, and a tray of scones back
- In the browser: postMessage(): a message each way
- In real life: The bakery can't serve her customers
- In the browser: A worker can't touch the page
A worker runs JavaScript on its own thread. Heavy work there stops competing with clicks and frames. It doesn't make the work smaller: it moves it. Moving pays when the work is big and the messages are small. Messages are copied, unless you transfer them.
The JavaScript lessons Web Workers, How workers work and Parallel patterns with workers teach the Worker API, what happens inside, and patterns like pools. This lesson measures what a worker does for speed, in five steps:
- What can move to a worker, and what moving saves.
- What a message costs, copied or transferred.
- Comlink, which makes a worker look like a set of functions.
- OffscreenCanvas, which lets a worker draw on the page.
- Shared memory, and the isolated page it needs.
How the course recorded it
- Six pages: lesson 27.3's tea list with its 10 ms pieces, three pages with a worker, and two pages that draw a chart. A lab page measured messages on its own. A local server sent them all over HTTPS.
- Asha's phone: this computer in Chrome 157, with its processor, the CPU, slowed 4 times. The server held this week's reviews back 1 second, as in lesson 27.3.
- The worker's speed: DevTools can slow the page's main thread, but not a worker. So most worker pages do each piece of work 4 times over, to stand in for Asha's phone. One runs at this computer's full speed. The next section checks how close the stand-in comes.
- Loads: each page loaded three times, taking turns. Each time, Chrome kept a trace: a detailed record of everything it did, with screenshots. The lesson quotes the middle load.
- Clicks: in every load, Asha clicks Add on Mint tea 150 ms after the reviews arrive. The click is a mouse press and release sent through Chrome's DevTools protocol. Chrome passes it straight to the page.
What can move to a worker
A worker has its own thread and its own global variables. It has no page. There's no document in a worker, so it can't read or change the DOM. web.dev lists more it can't reach, like WebUSB, WebRTC and Web Audio. What it can do is work on data: numbers, text and lists.
Lesson 24.2 showed one way around the missing page. Partytown ran third-party tags in a worker, and carried their questions about the page to the main thread. Ravi's own code is simpler to move. He splits reviews.js in two. The page keeps the jobs that need the page:
// reviews.js: hands this week's reviews to a worker, and shows each rating it sends back.const about = document.querySelector("#about");const onScreen = (tea) => document.querySelector(`[data-tea="${tea.id}"]`).getBoundingClientRect().top < innerHeight;const worker = new Worker("rate-worker.js");worker.postMessage([...TEAS.filter(onScreen), ...TEAS.filter((tea) => !onScreen(tea))]);worker.onmessage = (event) => { const { id, rating, read } = event.data; document.querySelector(`[data-tea="${id}"] .stars`).textContent = "★ " + rating; about.textContent = "Reviews read: " + read + " of 4000";};- Line 3 checks which teas are on screen. It reads the page's layout, so it stays on the main thread.
- Line 4 starts the worker.
new Worker()runs rate-worker.js on a thread of its own. - Line 5 sends the teas, the screen's six first.
postMessage()sends one message: here, the whole list. - Lines 6 to 10 run each time the worker sends a message back. Line 8 writes the tea's rating into its row. Line 9 updates the reviews line. Changing the DOM stays on the main thread too.
The worker gets the heavy part:
// rate-worker.js: reads every review on the worker's own thread.const SLOWER = 4; // 4 stands in for Asha's 4x slower CPU. DevTools can't slow a worker.let done = 0;const work = (count) => { let sum = 0; for (let i = 0; i < count * SLOWER; i++) sum += Math.sqrt(i); done += sum; };let read = 0;onmessage = (event) => { for (const tea of event.data) { let total = 0; for (const stars of tea.stars) { work(23_000); // reading one review's words total += stars; read += 1; } postMessage({ id: tea.id, rating: (total / tea.stars.length).toFixed(1), read }); }};- Lines 2 to 4 make the course's stand-in work,
work(). Line 2 setsSLOWER, which you'll meet in a moment. - Line 6 runs when the page's message arrives.
event.datais the list of teas. - Lines 9 to 13 read each review: 23,000 steps of work, then add its stars.
- Line 14 sends the tea's rating back as soon as it's ready.
Notice what's missing: there are no yields. Nothing else runs on the worker's thread, so no click waits for it. The HTML standard says workers allow "long tasks to be executed without yielding to keep the page responsive."
DevTools can't slow a worker
Measuring this has a catch. DevTools' CPU slowdown pauses one thread over and over: the page's main thread. Chromium's code starts it for the thread that asked, and only a page can ask. When the course asked for a worker, DevTools answered: "Operation is only supported for pages, not workers". So the course timed the same loop in both places:
// The same loop, timed where it ran, on Asha's phone. Middle of 7 runs, in ms.const main = 36.53; // the page's main thread, slowed 4 timesconst worker = 9; // a worker: DevTools' slowdown doesn't reach itconst workerSlower = 35.09; // a worker doing the work 4 times overconsole.log(`The main thread took ${(main / worker).toFixed(1)} times as long as the worker.`);console.log(`Done 4 times over, the worker took ${workerSlower} ms.`);- Line 2 is the loop on the page's main thread, slowed 4 times: 36.53 ms.
- Line 3 is the same loop in a worker: 9 ms. It ran at this computer's full speed.
- Line 4 is the worker doing the loop 4 times over: 35.09 ms.
- It prints
The main thread took 4.1 times as long as the worker.andDone 4 times over, the worker took 35.09 ms.
So doing the work 4 times over brings the worker close to Asha's phone. That's what SLOWER = 4 does. The course also recorded the worker with SLOWER = 1, at full speed. Why it matters: with DevTools' slowdown on, a worker still runs at your computer's speed. Its numbers will look better than a phone's.
Where the time went
With pieces, the main thread did everything. With the worker, most of its 91.2 ms went on drawing frames: 56.3 ms. Handling the worker's 40 messages took 14 ms. Read the table across. Each row is one version on Asha's phone. Times are in ms after reviews.js started.
| Version | Main thread busy | Worker busy | TBT | Frames drawn | Six ratings on screen | All 40 on screen |
|---|---|---|---|---|---|---|
| 10 ms pieces on the main thread (lesson 27.3) | 657.6 ms. | No worker. | 0 ms. | 10. | 95.6 ms. | 706 ms. |
| A worker, at this computer's speed | 40.1 ms. | 106.9 ms. | 0 ms. | 6. | 49.4 ms. | 133.2 ms. |
| A worker, standing in for Asha's phone | 91.2 ms. | 410.4 ms. | 0 ms. | 23. | 84.7 ms. | 430.5 ms. |
Two warnings come with these numbers.
- TBT was 0 with the pieces too. Total Blocking Time, or TBT, adds up the part of each long task over 50 ms. The 10 ms pieces already kept every task short. Here the worker's gain is the main thread's time and the frames.
- A stand-in is still a stand-in. The plain loop matched. But on the page, the stand-in worker showed all 40 ratings at 430.5 ms, and the pieces at 706 ms. Don't read that as the worker being faster. On a real phone, the worker runs on the same slow processor. web.dev says plainly that moving work "doesn't make your app faster".
Pick a version to see all its numbers. The fourth uses Comlink, which comes later:
| Main thread busy | 657.6 ms |
|---|---|
| Worker busy | no worker |
| Total Blocking Time | 0 ms |
| Frames drawn while reading | 10 |
| Longest time between two frames | 108.9 ms |
| Asha's click waited | 6.2 ms |
| Cart: 1 on screen, after the click | 51.2 ms |
| Her six ratings on screen | 95.6 ms |
| All 40 ratings on screen | 706 ms |
The main thread was busy 657.6 ms while the reviews were read, and Chrome drew 10 frames. Times for the ratings count from when reviews.js started.
A worker isn't free
Starting one costs time. On the lab page, a new worker answered its first message after 4.26 ms. That was this computer's worker thread, not slowed. The HTML standard warns that workers "have a high start-up performance cost, and a high per-instance memory cost." So start one worker early and keep it, instead of one per job. How workers work explains why.
What a message costs: copy or transfer
Every message crosses from one thread to the other. By default, postMessage() copies it. The copy is made by structured clone: the browser's way to copy a value for a message. You can call it yourself, as structuredClone().
Meera needs the bakery to see this week's orders. She can photocopy the folder and send the copy. A thin folder takes a minute. A thick one keeps her at the copier, and the bakery has to sort the copy too. Or she can hand over the folder itself. That takes no time at all. But now the folder is at the bakery, not on her shelf.
- In real life: Photocopying the folder
- In the browser: Copying a message with structured clone
- In real life: Meera's time at the copier
- In the browser: The sender's cost, in postMessage()
- In real life: The bakery sorting the copy
- In the browser: The receiver's cost, when it reads event.data
- In real life: Handing over the folder itself
- In the browser: Transferring an ArrayBuffer
- In real life: Her empty shelf
- In the browser: The sender's buffer, now empty
Here are both, with structuredClone. Press Edit & run to try it:
const photo = new ArrayBuffer(48_000_000); // a 12-megapixel photo's pixelsconst copy = structuredClone(photo);console.log(photo.byteLength, copy.byteLength);const moved = structuredClone(photo, { transfer: [photo] });console.log(photo.byteLength, moved.byteLength);- Line 1 makes an ArrayBuffer: a block of raw bytes. 48 million bytes is a 12-megapixel photo's pixels, at 4 bytes each.
- Line 2 copies it. Line 3 prints
48000000 48000000: two buffers, both full. - Line 4 copies it again, but lists it under
transfer. Line 5 prints0 48000000. The bytes moved tomoved, and the original is empty.
postMessage() takes the same list as its second argument: worker.postMessage(buffer, [buffer]). Only some objects can be transferred. ArrayBuffer is the common one. Plain objects and arrays are always copied.
Who pays for a copy? Both sides. Surma measured it for his article "Is postMessage slow?", and put it this way: "serialization blocks the sending realm, while deserialization blocks the receiving realm." Serializing turns the value into bytes for the trip. Deserializing builds it again on the other side. In Chrome, the receiver pays when it first reads event.data. Chromium's code rebuilds the data right there.
Ravi's message is small
The worker page sends one message with this week's teas. The lab page timed it:
// The worker page's one big message: this week's teas. Middle of 7, in ms.const teas = { json: 9154, send: 0.03, receive: 0.04 };console.log(`${(teas.json / 1000).toFixed(1)} KB as JSON: ${teas.send} ms to send, ${teas.receive} ms to read in the worker`);- Line 2 holds the teas' size as JSON text, 9,154 characters, and the two times.
- Line 3 prints
9.2 KB as JSON: 0.03 ms to send, 0.04 ms to read in the worker.
That's nothing next to 410.4 ms of work. Why it matters: a worker pays off when the work is big and the message is small.
Big messages: copy or transfer?
The lab page sent buffers of five sizes each way, seven times each, on Asha's phone. Its clock counts in steps of 0.005 ms; the last section says why. Here is what the main thread paid:
// The main thread's side of each message: [sending it, reading a reply that size]. Middle of 7, in ms.const sizes = [ { size: "1 KB", copy: [0.01, 0], transfer: [0.01, 0] }, { size: "100 KB", copy: [0.01, 0.02], transfer: [0.01, 0] }, { size: "1 MB", copy: [0.87, 0.12], transfer: [0.01, 0] }, { size: "10 MB", copy: [4.71, 6.39], transfer: [0.02, 0] }, { size: "48 MB", copy: [37.99, 26.79], transfer: [0.03, 0.03] },];for (const { size, copy, transfer } of sizes) { console.log(`${size}: copy ${copy[0]} + ${copy[1]}, transfer ${transfer[0]} + ${transfer[1]}`);}- Lines 3 to 7 hold one size each, from 1 KB to 48 MB.
copyholds two times: sending a copy to the worker, and reading a copy from it.transferholds the same two for a transfer. - Line 10 prints five lines. The last is
48 MB: copy 37.99 + 26.79, transfer 0.03 + 0.03.
Up to 100 KB, a copy cost hundredths of a millisecond. At 10 MB, sending and reading a copy took about 11 ms together. At 48 MB, sending a copy took 37.99 ms, and reading one took 26.79 ms. Each is longer than a whole frame, which is 16.7 ms at 60 frames a second. A transfer stayed under a tenth of a millisecond at every size.
Surma's advice fits: "even on the slowest devices, you can postMessage() objects up to 100KiB and stay within your budget." His budget is 100 ms for answering a click. With animations running, he keeps messages under 10 KiB. Why it matters: for anything big, like pixels or sound, put it in an ArrayBuffer and transfer it.
Pick a size to see both sides of each message:
| The main thread sends a copy | 0.87 ms |
|---|---|
| The worker reads that copy | 0.13 ms |
| The worker sends a copy | 0.11 ms |
| The main thread reads that copy | 0.12 ms |
| The main thread sends a transfer | 0.01 ms |
| The main thread reads a transfer | 0 ms |
Transferred, it paid 0.01 ms. Rows marked worker ran on the worker's thread, which DevTools didn't slow.
Now try it in your own browser. This lab starts a small worker that reads each message:
// What Send does, with the size you picked.const buffer = new ArrayBuffer(size);const start = performance.now();worker.postMessage(buffer, transfer ? [buffer] : []);const send = performance.now() - start;console.log(send, buffer.byteLength); // the time, and the bytes left herePick a size, then press Send a copy or Transfer it. Try both at each size.
Calling a worker like a function
Ravi's worker understands one message. A real app sends many kinds: rate these, search that, sort those. Parallel patterns with workers built a helper for that. Each request gets an ID, and each reply finds its promise. That's a remote procedure call, or RPC: a function call that runs somewhere else. Comlink is a small library from Google Chrome Labs that does it for you.
At a café, you don't walk to the kitchen. You tell the waiter "a pot of mint tea", as if the kitchen were at your table. The waiter writes a slip, takes it in, and brings the pot back. It feels like just asking. But every order still makes a trip.
- In real life: Asking the waiter for mint tea
- In the browser: Calling rater.rate(tea)
- In real life: The slip and the walk to the kitchen
- In the browser: A message with an ID
- In real life: The pot coming back
- In the browser: The reply that settles the promise
- In real life: Every order makes a trip
- In the browser: Every call is a round trip
Here is the worker with Comlink 4.4.2:
// rate-worker.js, with Comlink: exposes rate() to the page.importScripts("comlink.js");const SLOWER = 4; // 4 stands in for Asha's 4x slower CPU. DevTools can't slow a worker.let done = 0;const work = (count) => { let sum = 0; for (let i = 0; i < count * SLOWER; i++) sum += Math.sqrt(i); done += sum; };Comlink.expose({ rate(tea) { let total = 0; for (const stars of tea.stars) { work(23_000); // reading one review's words total += stars; } return (total / tea.stars.length).toFixed(1); },});- Line 2 loads Comlink into the worker.
importScripts()runs another script file inside a worker. - Lines 6 to 15 offer the page one function,
rate(). It reads one tea's reviews and returns its rating.
And the page:
// reviews.js, with Comlink: the worker's rate() looks like an ordinary async function.const about = document.querySelector("#about");const onScreen = (tea) => document.querySelector(`[data-tea="${tea.id}"]`).getBoundingClientRect().top < innerHeight;const rater = Comlink.wrap(new Worker("rate-worker.js"));async function rateAll(teas) { let read = 0; for (const tea of teas) { const rating = await rater.rate(tea); read += tea.stars.length; document.querySelector(`[data-tea="${tea.id}"] .stars`).textContent = "★ " + rating; about.textContent = "Reviews read: " + read + " of 4000"; }}rateAll([...TEAS.filter(onScreen), ...TEAS.filter((tea) => !onScreen(tea))]);- Line 4 wraps the worker.
raterlooks like the object the worker offered. - Line 8 calls
rater.rate(tea). Comlink's README says calls are "inherently asynchronous". So it returns a promise, andawaitwaits for it. - Lines 9 to 11 count the reviews and write the rating, as before.
Inside, it's still postMessage(). Comlink's source gives each call a new ID and sends one message. The reply with the same ID settles the promise. Every argument and result is copied, by default. Comlink.transfer() marks a buffer to transfer instead.
What Comlink cost
First, its size. Comlink's minified file is 4,721 bytes, and 2,069 compressed with gzip. Then the time per call, on the lab page:
// 40 tiny calls to a worker, one after another, on Asha's phone. Middle call, in ms.const plain = 0.01; // postMessage, then wait for the replyconst comlink = 0.03; // await api.next(n)console.log(`Plain messages: ${plain} ms a call. Comlink: ${comlink} ms a call.`);- Lines 2 and 3 hold the middle of 40 tiny calls, one after another, each way.
- Line 4 prints
Plain messages: 0.01 ms a call. Comlink: 0.03 ms a call.
Comlink itself is cheap. But the Comlink page was slower: all 40 ratings were on screen at 619.9 ms, against 430.5 ms with plain messages. The worker was also busy longer, 520.9 ms against 410.4 ms. Look at line 8 again. It waits for each tea before asking about the next. So 40 round trips sit between the pieces of work, and the worker waits too. The plain page sent all the teas in one message.
Why it matters: a wrapper makes a worker easy to call, but a call is still a message. Ask for big jobs, not many small ones. One call could take every tea, with Comlink.proxy() around a function the worker calls back for each rating.
Drawing in a worker with OffscreenCanvas
Meera's next wish is a chart: one dot for each of this week's 4,000 reviews. There's a column for each tea and a row for each star. The page draws it on a canvas: an HTML element that JavaScript paints pixels on.
// chart-draw.js: one dot for each of this week's 4000 reviews. A column per tea, a row per star.function drawChart(context, teas) { context.fillStyle = "#1f6f4a"; teas.forEach((tea, column) => { tea.stars.forEach((stars, index) => { work(10_000); // working out where the dot goes context.fillRect(4 + column * 9.6 + (index % 7), 100 - stars * 18 + (Math.floor(index / 7) % 12), 2, 2); }); });}- Line 2 takes a drawing context, the object you draw with, and the teas.
- Line 6 does 10,000 steps of work for each dot. It stands in for working out where the dot goes.
- Line 7 paints the dot: a square 2 pixels wide.
On the first chart page, chart.js calls drawChart() on the main thread, once the reviews arrive. That's one task. Asha clicks Add while it runs. But a canvas belongs to the page. Can a worker draw on it?
Leaf and Kettle's shop window has a small door at the back. A window dresser can step in and change the display from there. She never walks through the shop, so Meera keeps serving. Once Meera gives her the key, only the dresser works on the window.
- In real life: The shop window
- In the browser: The canvas on the page
- In real life: Giving her the key
- In the browser: transferControlToOffscreen()
- In real life: The window dresser at work
- In the browser: The worker drawing the chart
- In real life: Meera, still serving
- In the browser: The main thread, free for clicks
That key is OffscreenCanvas. Here is the second chart page:
// chart.js: hands the canvas to a worker, which draws the chart.const canvas = document.querySelector("#chart").transferControlToOffscreen();const worker = new Worker("chart-worker.js");worker.postMessage({ canvas, teas: TEAS }, [canvas]);- Line 2 calls
transferControlToOffscreen(). It returns an OffscreenCanvas: a canvas with no page attached. The canvas element stays on the page and shows what the OffscreenCanvas draws. - Line 3 starts the worker.
- Line 4 sends the canvas and the teas. The canvas goes in the transfer list: an OffscreenCanvas can be transferred, not copied.
// chart-worker.js: draws on the page's canvas from the worker's own thread.importScripts("chart-draw.js");const SLOWER = 4; // 4 stands in for Asha's 4x slower CPU. DevTools can't slow a worker.let done = 0;const work = (count) => { let sum = 0; for (let i = 0; i < count * SLOWER; i++) sum += Math.sqrt(i); done += sum; };onmessage = (event) => drawChart(event.data.canvas.getContext("2d"), event.data.teas);- Line 2 loads the same
drawChart()into the worker. - Line 6 gets a context from the OffscreenCanvas, and draws. Same code, other thread.
The HTML standard pushes each finished drawing to the page's canvas. It encourages browsers to show it without waiting for the page's main thread. That helps most "in cases where the OffscreenCanvas is updated from a worker event loop and the window event loop of the placeholder canvas element is busy." Here is what Asha got:
// Asha clicks Add while the chart is drawn. In ms after her click, from the recording.const pages = [ { name: "Main thread", cart: 154.7, chart: 154.7 }, { name: "Worker", cart: 39.3, chart: 124.8 },];for (const page of pages) { console.log(`${page.name}: Cart: 1 on screen at ${page.cart} ms, the whole chart at ${page.chart} ms`);}- Lines 3 and 4 hold each chart page: when Cart: 1 reached the screen, and when the whole chart did.
- Line 7 prints
Main thread: Cart: 1 on screen at 154.7 ms, the whole chart at 154.7 ms. Then it printsWorker: Cart: 1 on screen at 39.3 ms, the whole chart at 124.8 ms.
On the main thread, the drawing was one task of 265.4 ms. Asha's click waited 116.6 ms for it to end, and TBT was 215.4 ms. With the worker, her click waited 1.2 ms, and TBT was 0. Here is her screen, 60 ms and 140 ms after she clicked:
The worker took about as long to draw: 250.4 ms. It just stopped blocking her. Why it matters: slow drawing can leave the main thread and still land on the page.
Where should this work run?
Each job below comes from a real shop page. Sort it by where it should run:
- Finding which tea rows are on screen
- Changing a button's text to Added
- Stopping a form from sending with preventDefault()
- Working out 40 ratings from 4,000 reviews
- Parsing a 5 MB file of products
- Shrinking a photo's pixels before upload
- Drawing a chart of 4,000 dots on a canvas
- Drawing every frame of a canvas game
Sort each job by the thread that should do it.
Common mix-ups
- "A worker makes the work faster." No. It moves the work to another thread. The main thread's share fell from 657.6 ms to 91.2 ms, and the worker did 410.4 ms of it.
- "Sending data to a worker is free." No. A copy costs both sides. At 48 MB, sending one took the main thread 37.99 ms.
- "Transferring a buffer copies it quickly." No. It moves the bytes. The sender's buffer is left empty, with a byteLength of 0.
- "DevTools' CPU slowdown slows workers too." No. It slows the page's main thread only. The same loop took 36.53 ms there and 9 ms in a worker.
- "Comlink shares objects between threads." No. It sends messages. Arguments and results are copied, unless you transfer them.
- "Any page can use SharedArrayBuffer." No. Only a cross-origin isolated page can. Without COOP and COEP, the constructor is hidden.
Practice exercises
Answer first, then use the hints.
What does this program print?
const tea = { id: "mint-tea", stars: [5, 4] };
const sent = structuredClone(tea); // the copy postMessage makes
sent.stars.push(1);
console.log(tea.stars.length, sent.stars.length);2 3. The copy has its own stars list, so pushing to it leaves the original alone. A worker's copy of a message works the same way: its changes never reach the page.
What does this program print?
const stars = new Uint8Array([5, 4, 5, 3]);
const moved = structuredClone(stars, { transfer: [stars.buffer] });
console.log(stars.length, moved.length);0 4. The four bytes moved to moved. The original Uint8Array still exists, but its buffer is empty, so its length is 0.
In the versions playground, pick the worker with Comlink. How long was the main thread busy, in ms?
128.9 ms. More than the 91.2 ms with plain messages: each of the 40 calls is its own message and reply, and Chrome drew more frames while the work took longer.
In the message playground, pick 10 MB. How long did the worker take to read a copy, in ms?
1.3 ms. The worker's thread wasn't slowed, so its side of the copy was quicker than the main thread's. On a phone, both sides would be slow.
Open the course's trace of the worker page in Chrome's DevTools. Find the worker's track, and click its longest task. How long did it run, in ms?
About 408.6 ms. One task read every review, and the worker never yielded. It didn't need to: nothing else runs on that thread, and no click waits for it. DevTools names the track Worker, then the script's address.
In Comlink's source, which message type does Comlink send when you call a function on a wrapped worker?
APPLY. Calling a function on the wrapped worker sends a message of type APPLY, with the function's path and its arguments, and an ID for the reply.
Check your understanding
For each question, ask which thread does the work, and what crosses between them.
Question 1 of 7reviews.js moved its reading into rate-worker.js. Which job had to stay on the main thread?
Choose an answer to see the explanation.
Question 2 of 7What does this count of messages print?
Read the code, then predict// A page awaits a worker's rate() for each of 40 teas, one after another. let messages = 0; for (let tea = 1; tea <= 40; tea++) { messages += 1; // the call goes to the worker messages += 1; // the answer comes back } console.log(`${messages} messages`);Choose an answer to see the explanation.
Question 3 of 7The page sends a 48 MB ArrayBuffer with postMessage(buffer, [buffer]). What is buffer.byteLength on the page right after?
Choose an answer to see the explanation.
Question 4 of 7Why did the course make the worker do each review's work 4 times over?
Choose an answer to see the explanation.
Question 5 of 7Which part of sending a message grows as the message grows?
Choose an answer to see the explanation.
Question 6 of 7With the chart drawn in a worker, why did Asha's click get its answer so much sooner?
Choose an answer to see the explanation.
Question 7 of 7On a page, typeof SharedArrayBuffer is "undefined". What does the page need?
Choose an answer to see the explanation.
Key takeaways
- A worker runs JavaScript on its own thread. It moves work off the main thread; it doesn't make it smaller.
- Work on data can move. Anything that reads or changes the page stays on the main thread.
- DevTools' CPU slowdown doesn't reach a worker. Measure the worker's own time, and remember a phone is slower.
- A message is copied by default, and both sides pay. Transfer big ArrayBuffers instead: the cost stays tiny.
- Comlink makes calls look local, but each call is a round trip. Ask for big jobs.
- OffscreenCanvas lets a worker draw on the page, while the main thread answers clicks.
- SharedArrayBuffer needs a cross-origin isolated page: HTTPS, COOP and COEP.
Remember the one-liner.
Move big work to a worker, send small messages, and transfer what's large.
Sources
The facts on this page come from these specs, docs and source files:
- HTML Standard: Web workers, WHATWG.
- Use web workers to run JavaScript off the browser's main thread, web.dev.
- inspector_emulation_agent.cc, Chromium 157.0.8103.52, Chromium on GitHub.
- thread_cpu_throttler.cc, Chromium 157.0.8103.52, Chromium on GitHub.
- HTML Standard: Safe passing of structured data, WHATWG.
- Transferable objects, MDN.
- Worker: postMessage() method, MDN.
- message_event.cc, Chromium 157.0.8103.52, Chromium on GitHub.
- Is postMessage slow?, Surma.
- Comlink README, version 4.4.2, GoogleChromeLabs on GitHub.
- comlink.ts, version 4.4.2, GoogleChromeLabs on GitHub.
- OffscreenCanvas, MDN.
- HTML Standard: The OffscreenCanvas interface, WHATWG.
- SharedArrayBuffer, MDN.
- Window: crossOriginIsolated property, MDN.
- Making your website cross-origin isolated using COOP and COEP, web.dev.
- HTML Standard: Cross-origin opener policies, WHATWG.
- ThreadAppender.ts, Chrome DevTools, ChromeDevTools on GitHub.
Coming next: WebAssembly for performance. Some work runs faster in WebAssembly, and some doesn't. You'll measure which, and what it costs to load.