completefrontend
Open lab My progress
THE WEB PERFORMANCE FIELD GUIDE

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.

By the end, you can
  • 01
    Move 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.
  • 02
    Keep messages cheapKnow what copying a message costs on each side, and transfer big buffers instead of copying them.
  • 03
    Draw 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:

Where reviews.js's work ranPop out in the code editor (opens in a new tab)JavaScript
// 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. mainThread is how long the main thread was busy while the reviews were read. worker is how long the worker was busy. frames counts the frames Chrome showed.
  • Line 7 prints 10 ms pieces: main thread 657.6 ms, worker 0 ms, 10 frames. Then it prints A 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:

Real frames of Asha's screen as she clicked Add on Mint tea, and every frame Chrome drew while the reviews were read, in ms after reviews.js started. 10 ms pieces: as she clicked, the frame on screen was 53.2 ms old and showed Cart: 0 and 600 reviews read. Cart: 1 appeared 51.2 ms after her click. The main thread was busy 657.6 ms, the worker 0 ms, and Chrome drew 10 frames while the reviews were read. In a worker: as she clicked, the frame on screen was 10.8 ms old and showed Cart: 0 and 1,200 reviews read. Cart: 1 appeared 36.7 ms after her click. The main thread was busy 91.2 ms, the worker 410.4 ms, and Chrome drew 23 frames while the reviews were read.10 mspiecesAs she clickedThe tea list with Cart: 0, 6 of 6 ratings on screen, and Reviews read: 600 of 400053.2 ms oldCart: 0, 600 readFirst Cart: 1The tea list with Cart: 1, 6 of 6 ratings on screen, and Reviews read: 1000 of 400051.2 ms laterCart: 1, 1,000 readMain thread busy657.6 msWorker busy0 msFrames drawn10framesclickIn aworkerAs she clickedThe tea list with Cart: 0, 6 of 6 ratings on screen, and Reviews read: 1200 of 400010.8 ms oldCart: 0, 1,200 readFirst Cart: 1The tea list with Cart: 1, 6 of 6 ratings on screen, and Reviews read: 1500 of 400036.7 ms laterCart: 1, 1,500 readMain thread busy91.2 msWorker busy410.4 msFrames drawn23framesclickreviews.js starts300 ms600 ms
Real frames from the course's recording on Asha's phone. Each tick in a row is one frame Chrome drew while the reviews were read. With the worker, the main thread was free, so the frames came often and her screen stayed fresh.

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.

Real-life analogyOrdering scones from the bakery next door

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
The main idea

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:

  1. What can move to a worker, and what moving saves.
  2. What a message costs, copied or transferred.
  3. Comlink, which makes a worker look like a set of functions.
  4. OffscreenCanvas, which lets a worker draw on the page.
  5. 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: the page's halfJavaScript
// 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: the worker's halfJavaScript
// 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 sets SLOWER, which you'll meet in a moment.
  • Line 6 runs when the page's message arrives. event.data is 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, on the main thread and in a workerPop out in the code editor (opens in a new tab)JavaScript
// 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. and Done 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

How long each thread was busy while the reviews were read on Asha's phone, to scale. 10 ms pieces: the main thread 657.6 ms, no worker. A worker: the main thread 91.2 ms, of which 56.3 ms drawing frames and 14 ms handling messages; the worker 410.4 ms.10 ms piecesMain thread657.6 msWorkerno workerA worker, for Asha's phoneMain thread91.2 ms: 56.3 ms on frames, 14 ms on messagesWorker410.4 ms0 ms300 ms600 ms
The same reviews, read two ways, from the course's recording. The work moved to the worker. What stayed on the main thread was mostly drawing frames and handling the worker's messages.

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.

Three versions on Asha's phone
VersionMain thread busyWorker busyTBTFrames drawnSix ratings on screenAll 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 speed40.1 ms.106.9 ms.0 ms.6.49.4 ms.133.2 ms.
A worker, standing in for Asha's phone91.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:

Playground: the same reviews, four ways
Asha's phone10 ms pieces
Main thread busy657.6 ms
Worker busyno worker
Total Blocking Time0 ms
Frames drawn while reading10
Longest time between two frames108.9 ms
Asha's click waited6.2 ms
Cart: 1 on screen, after the click51.2 ms
Her six ratings on screen95.6 ms
All 40 ratings on screen706 ms
Try it yourself
10 ms pieces on the main thread (lesson 27.3)

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.

From the course's recording in Chrome 157, on Asha's phone: this computer with its CPU slowed 4 times. Each version is the middle of three loads. The worker versions for Asha's phone do each piece of work 4 times over, because DevTools can't slow a worker.

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

Real-life analogyPhotocopying a folder, or handing it over

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:

Copy a photo's pixels, then transfer themPop out in the code editor (opens in a new tab)JavaScript
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 prints 0 48000000. The bytes moved to moved, 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:

What the teas cost to sendPop out in the code editor (opens in a new tab)JavaScript
// 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 messagePop out in the code editor (opens in a new tab)JavaScript
// 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. copy holds two times: sending a copy to the worker, and reading a copy from it. transfer holds 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.

The main thread's time to send a buffer to a worker and read one back, on Asha's phone, to scale. 1 KB: copied 0.01 ms, transferred 0.01 ms. 100 KB: copied 0.03 ms, transferred 0.01 ms. 1 MB: copied 0.99 ms, transferred 0.01 ms. 10 MB: copied 11.1 ms, transferred 0.02 ms. 48 MB: copied 64.78 ms, transferred 0.06 ms.1 KBcopy 0.01 mstransfer 0.01 ms100 KBcopy 0.03 mstransfer 0.01 ms1 MBcopy 0.99 mstransfer 0.01 ms10 MBcopy 11.1 mstransfer 0.02 ms48 MBcopy 64.78 mstransfer 0.06 ms0 ms20 ms40 ms60 ms
Each pair of bars is one size, from the course's lab page: sending a buffer and reading one back, copied or transferred. Copies grow with the size. Transfers stay too small to see.

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:

Playground: one message, copied or transferred
Each side of the message1 MB
The main thread sends a copy0.87 ms
The worker reads that copy0.13 ms
The worker sends a copy0.11 ms
The main thread reads that copy0.12 ms
The main thread sends a transfer0.01 ms
The main thread reads a transfer0 ms
Try it yourself
1 MB: copied, the main thread paid 0.99 ms to send one and read one

Transferred, it paid 0.01 ms. Rows marked worker ran on the worker's thread, which DevTools didn't slow.

From the course's lab page in Chrome 157, on Asha's phone. Each time is the middle of 7 messages. Sending is the postMessage() call; reading is the first read of event.data.

Now try it in your own browser. This lab starts a small worker that reads each message:

Playground: copy or transfer, in your browser
What Send doesJavaScript
// 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 here
Your messages0 sent
    Try it yourself
    What your browser did

    Pick a size, then press Send a copy or Transfer it. Try both at each size.

    Real messages to a real worker on this page, timed by your browser. Your times depend on your device, and change from run to run.

    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 per reviewJavaScript
    // 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?

    Real-life analogyA window dresser with her own door

    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: hand the canvas to a workerJavaScript
    // 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: draw from the workerJavaScript
    // 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's click, while the chart is drawnPop out in the code editor (opens in a new tab)JavaScript
    // 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 prints Worker: 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:

    Real frames of Asha's screen 60 ms and 140 ms after she clicked Add, while the chart was drawn. Chart on the main thread: 60 ms after, Cart: 0, no chart; 140 ms after, Cart: 0, no chart. Her click waited 116.6 ms, and TBT was 215.4 ms. Chart in a worker: 60 ms after, Cart: 1, no chart; 140 ms after, Cart: 1, chart. Her click waited 1.2 ms, and TBT was 0 ms.Chart on themain thread60 ms afterThe tea list with Cart: 0 and an empty chartCart: 0, no chart140 ms afterThe tea list with Cart: 0 and an empty chartCart: 0, no chartClick waited116.6 msTBT215.4 msChart ina worker60 ms afterThe tea list with Cart: 1 and an empty chartCart: 1, no chart140 ms afterThe tea list with Cart: 1 and the whole chart of dotsCart: 1, chartClick waited1.2 msTBT0 ms
    Real frames from the course's recording on Asha's phone. With the chart on the main thread, her screen stood still until the drawing ended. With the chart in a worker, Cart: 1 came first, and the chart followed.

    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.

    Shared memory needs an isolated page

    Messages copy data or move it. A SharedArrayBuffer does neither. It's a block of bytes that the page and its workers can all read and write at once. Parallel patterns with workers explains it, and Atomics: functions that read and write shared bytes safely.

    Ravi tries it for the reviews line. The worker adds one to a shared count after each review. The page reads the count before each frame. There's no message per review at all:

    progress-worker.js: count each reviewJavaScript
    // progress-worker.js: adds one to the shared count after each review.onmessage = (event) => {  const { teas, progress } = event.data;  for (const tea of teas) {    for (const stars of tea.stars) {      work(23_000); // reading one review's words      Atomics.add(progress, 0, 1);    }  }};
    • Line 3 takes the teas and progress: an Int32Array, a list of whole numbers. Its one number lives in shared memory.
    • Line 7 adds 1 to the count with Atomics.add(). The page never sees a half-written number.
    The page: read the count before each frameJavaScript
    // The page: one number in shared memory, read before each frame.const progress = new Int32Array(new SharedArrayBuffer(4));worker.postMessage({ teas: TEAS, progress });requestAnimationFrame(function show() {  const read = Atomics.load(progress, 0);  about.textContent = "Reviews read: " + read + " of 4000";  if (read < 4000) requestAnimationFrame(show);});
    • Line 2 makes the shared memory: 4 bytes, room for one number.
    • Line 3 sends it. The page and the worker now hold the same bytes.
    • Lines 4 to 8 read the count with Atomics.load() before each frame, and stop at 4,000.

    On the lab page, the reviews line showed 9 counts in 134.1 ms, from 0 to 4,000. Each frame read the count with no message at all.

    Real-life analogyA whiteboard behind a locked door

    Meera and the bakery share a whiteboard on the wall between them. The baker adds a tally mark for each tray. Meera glances at it whenever she likes, without asking. But the landlord allows a shared board only if both doors are locked to strangers.

    In real life: The whiteboard on the shared wall
    In the browser: A SharedArrayBuffer
    In real life: Adding a tally mark
    In the browser: Atomics.add()
    In real life: A glance at the board
    In the browser: Atomics.load()
    In real life: Both doors locked to strangers
    In the browser: A cross-origin isolated page

    Why the lock? MDN says browsers turned off shared memory and precise timers in 2018, because of Spectre. Spectre is an attack that reads memory it shouldn't, by timing things very precisely. So today a page gets shared memory only when it is cross-origin isolated: kept apart from other sites' windows and files. Two headers from the server do that:

    The two headers that isolate a pageHTTP
    Cross-Origin-Opener-Policy: same-origin
    Cross-Origin-Embedder-Policy: require-corp
    • Line 1 is COOP, the Cross-Origin Opener Policy. It keeps other sites' windows out of the page's group, so neither can reach the other.
    • Line 2 is COEP, the Cross-Origin Embedder Policy. The page may load another origin's files only if they agree to it. credentialless is the other value that works.

    The page must also be a secure context: served over HTTPS, or from your own computer. The HTML standard ignores COOP anywhere else. The course served the same lab page twice over HTTPS, with the headers and without:

    The lab page, with and without the headersPop out in the code editor (opens in a new tab)JavaScript
    // The lab page on shop.example over HTTPS, with and without the two headers. From the recording.const pages = [  { page: "Without the headers", isolated: false, sharedArrayBuffer: "undefined", posting: "DataCloneError" },  { page: "With them", isolated: true, sharedArrayBuffer: "function", posting: "posted" },];for (const page of pages) {  console.log(`${page.page}: crossOriginIsolated ${page.isolated}, SharedArrayBuffer is ${page.sharedArrayBuffer}, posting shared memory: ${page.posting}`);}
    • Lines 3 and 4 hold what each page reported.
    • Line 7 prints Without the headers: crossOriginIsolated false, SharedArrayBuffer is undefined, posting shared memory: DataCloneError. Then it prints With them: crossOriginIsolated true, SharedArrayBuffer is function, posting shared memory: posted.

    Without the headers, Chrome hid SharedArrayBuffer, as the HTML standard says to. The page could still make shared memory through WebAssembly. But posting it to a worker threw DataCloneError.

    Isolation costs something too. On the isolated page, an image from images.example, the shop's image server, didn't load. It loaded once that server sent Cross-Origin-Resource-Policy: cross-origin with it. Every image, script and frame from another site needs that, or CORS. And one more change came with isolation: performance.now() counted in steps of 0.005 ms, against 0.1 ms without. Why it matters: isolate a page only when it really needs shared memory, and check its third parties first.

    Playground: is this page isolated?
    Three pagesnot checked
    What was readThis pageLab, no headersLab, with headers
    Secure contextnot checkedtruetrue
    crossOriginIsolatednot checkedfalsetrue
    typeof SharedArrayBuffernot checkedundefinedfunction
    Smallest clock stepnot checked0.1 ms0.005 ms
    Try it yourself
    Press Check this page

    The lab reads this page's crossOriginIsolated and SharedArrayBuffer, and times the steps of its clock for 20 ms.

    The first column is this page, read by your browser now. The other two are the course's lab page in Chrome, recorded with and without the two headers.

    Where should this work run?

    Each job below comes from a real shop page. Sort it by where it should run:

    Where should this work 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
    Try it yourself
    0 of 8 correct

    Sort each job by the thread that should do it.

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

    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.

    Exercise 1 · Warm-upA copy is its own

    What does this program print?

    Starter codePop out in the code editor (opens in a new tab)JavaScript
    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);

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

      Exercise 2 · Warm-upMoved, not copied

      What does this program print?

      Starter codePop out in the code editor (opens in a new tab)JavaScript
      const stars = new Uint8Array([5, 4, 5, 3]);
      const moved = structuredClone(stars, { transfer: [stars.buffer] });
      console.log(stars.length, moved.length);

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

        Exercise 3 · PracticeThe Comlink page's main thread

        In the versions playground, pick the worker with Comlink. How long was the main thread busy, in ms?

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

          Exercise 4 · PracticeThe worker's side of a copy

          In the message playground, pick 10 MB. How long did the worker take to read a copy, in ms?

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

            Exercise 5 · ChallengeThe worker's longest task

            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?

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

              Exercise 6 · ChallengeComlink's message for a call

              In Comlink's source, which message type does Comlink send when you call a function on a wrapped worker?

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

                Check your understanding

                For each question, ask which thread does the work, and what crosses between them.

                The web workers for performance quiz · 7 questionsScore: first tries count
                1. 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.

                2. Question 2 of 7What does this count of messages print?

                  Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                  // 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.

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

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

                5. Question 5 of 7Which part of sending a message grows as the message grows?

                  Choose an answer to see the explanation.

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

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

                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.

                CompleteFrontend Clear concepts. Working examples.