completefrontend
Open lab My progress
THE WEB PERFORMANCE FIELD GUIDE

WebAssembly for performance

Measure when WebAssembly is faster than JavaScript and when it isn't, what a .wasm file costs to load, and how SIMD, threads, WasmGC and JSPI change the answer.

By the end, you can
  • 01
    Pick work that WebAssembly speeds upTell a big loop over numbers from work that is small, waits for the network, or that the browser already does well.
  • 02
    Count every cost, not just the loopMeasure the download, the compile on first use, the copies into Wasm memory, and the cost of each call.
  • 03
    Use SIMD and threads where they payBuild a SIMD version with a fallback, share memory between workers, and know what WasmGC and JSPI are for.

Would WebAssembly brighten Asha's photo sooner?

Asha is writing a review of Mint tea. She adds a photo she took in the shop. The shop was dim, so the photo came out dark.

Meera wants a Brighten button. One tap, and the photo gets lighter before it uploads. Ravi writes it in JavaScript. It adds 40 to the red, green and blue of every pixel.

Asha's photo is big. Her phone's camera takes 12 megapixels: 12 million pixels. Each pixel is 4 bytes, so Brighten has 48 million numbers to look at.

Ravi has heard that WebAssembly is faster than JavaScript. So he asks a question. If he rewrote brighten() in WebAssembly, would Asha see her brighter photo sooner? Or not?

The JavaScript lesson JavaScript & WebAssembly showed what WebAssembly is. Here is the short version. WebAssembly, or Wasm, is code in a compact binary format. Browsers run it next to JavaScript. You write it in another language, and a compiler turns it into a .wasm file.

Ravi wrote two Wasm versions. One is plain. The other uses SIMD: a way to change 16 bytes in one step. The course timed all three on Asha's phone. Here is when her brighter photo appeared:

When Asha's brighter photo appearedPop out in the code editor (opens in a new tab)JavaScript
// When Asha's brighter photo reached her screen, in ms after her tap.const taps = [  { way: "JavaScript", shown: 147.5 },  { way: "WebAssembly", shown: 209.9 },  { way: "WebAssembly with SIMD", shown: 106.7 },];for (const tap of taps) console.log(`${tap.way}: ${tap.shown} ms`);
  • Line 1 is a comment. Times are in milliseconds (ms). A millisecond is a thousandth of a second.
  • Lines 3 to 5 hold the three versions. shown is how long after her tap the brighter photo reached her screen.
  • Line 7 prints JavaScript: 147.5 ms, WebAssembly: 209.9 ms and WebAssembly with SIMD: 106.7 ms.

Here is what Asha saw. These are real frames Chrome captured. A frame is one picture the browser draws on the screen.

Real frames of Asha's review page: her dark photo as she tapped Brighten, and the brighter photo that came after. JavaScript: the brighter photo reached her screen 147.5 ms after her tap. WebAssembly: the brighter photo reached her screen 209.9 ms after her tap. WebAssembly with SIMD: the brighter photo reached her screen 106.7 ms after her tap. No frame was drawn in between.As she tappedAsha's review page with her dark photo of mint teaThe brighter photoThe same page with her photo brighterJavaScript147.5 msWebAssembly209.9 msWebAssembly with SIMD106.7 msher tap100 ms200 ms
Real frames from the course's recording on Asha's phone. Each bar is how long her screen still showed the dark photo after her tap. Chrome drew no frame in between, in any version.

So the answer is: it depends. Plain WebAssembly made Asha wait longer, 209.9 ms against 147.5 ms. The version with SIMD was quicker, at 106.7 ms.

Even with SIMD, the tap took nearly three quarters of JavaScript's time. Why it matters: Wasm doesn't make every job faster. You have to measure your own work, the whole tap and not just the loop.

Real-life analogyA dishwasher for the washing-up

After lunch, Meera has one cup to wash. She washes it by hand in a minute. Loading the dishwasher would take longer than that. After a party, there are 200 plates. Now the machine wins easily. But someone still had to buy it, plumb it in, load it and empty it.

In real life: Washing up by hand
In the browser: JavaScript, already on the page
In real life: The dishwasher
In the browser: A Wasm module
In real life: Buying it and plumbing it in
In the browser: Downloading and compiling the .wasm file
In real life: Loading the racks and emptying them
In the browser: Copying data into Wasm's memory and back
In real life: One cup, or a party's 200 plates
In the browser: A thumbnail, or a camera photo
The main idea

WebAssembly can make a big loop over numbers faster. But it only speeds up the loop. The download, the first call and the copies cost time too. Measure the whole job, at the sizes your users have.

This lesson measures each part, in five steps:

  1. Which work gets faster, and at which sizes.
  2. What a .wasm file costs to load.
  3. What it costs to cross between JavaScript and Wasm.
  4. SIMD and threads, which do more work per step.
  5. WasmGC and JSPI, two newer features.

How the course recorded it

  • Two pages: Asha's review page, in three versions, and a lab page that timed each part on its own. A local server sent them over HTTPS.
  • Asha's phone: this computer in Chrome 157, with its processor, the CPU, slowed 4 times.
  • The Wasm: Ravi wrote it in AssemblyScript, a language that looks like TypeScript. The course built it with AssemblyScript's compiler, version 0.28.20.
  • Loads: each version loaded three times, taking turns. The lesson quotes the middle one. Each time, Chrome kept a trace: a detailed record of everything it did, with screenshots.
  • Taps: Asha's tap is a mouse press and release sent through Chrome's DevTools protocol. Chrome passes it straight to the page.

Which work gets faster in WebAssembly

First, the two brighten() functions side by side. Here is Ravi's JavaScript:

brighten.js: the JavaScript versionJavaScript
// brighten.js: adds 40 to each pixel's red, green and blue.function brighten(pixels) {  for (let i = 0; i < pixels.length; i += 4) {    pixels[i] += 40; // red: a Uint8ClampedArray stops at 255    pixels[i + 1] += 40; // green    pixels[i + 2] += 40; // blue  }}
  • Line 2 takes pixels, a Uint8ClampedArray. That's a list of bytes that stops at 255 instead of wrapping around. The page gets one from the canvas that shows the photo. It holds red, green, blue and alpha for each pixel, one after another. Alpha is how see-through the pixel is.
  • Line 3 walks the list 4 bytes at a time: one pixel per step.
  • Lines 4 to 6 add 40 to red, green and blue. Alpha stays as it is.

And here is the same loop in AssemblyScript:

brighten.ts: the same loop, in AssemblyScriptAssemblyScript
// brighten.ts: adds 40 to each pixel's red, green and blue.export function brighten(start: usize, end: usize): void {  for (let i = start; i < end; i += 4) {    store<u8>(i, min<u32>(<u32>load<u8>(i) + 40, 255));    store<u8>(i + 1, min<u32>(<u32>load<u8>(i + 1) + 40, 255));    store<u8>(i + 2, min<u32>(<u32>load<u8>(i + 2) + 40, 255));  }} // One value at a time, for the page to call once per value.export function liftOne(value: u32): u32 {  return min<u32>(value + 40, 255);}
  • Line 2 takes two numbers, start and end. They are places in Wasm's memory, not a list. The function can't take a JavaScript array, so the photo has to sit in Wasm's memory.
  • Line 3 walks from start to end, 4 bytes at a time.
  • Line 4 reads one byte with load<u8>(i). <u32> makes it a bigger number, so 250 + 40 can reach 290. min() then stops it at 255, and store<u8>() writes it back.
  • Lines 5 and 6 do the same for green and blue.
  • Lines 11 to 13 are liftOne(), the same sum for one value. You'll use it later.

The course built it with npx asc brighten.ts -O3 -o brighten.wasm. The finished file, brighten.wasm, is 193 bytes. Here is how the page uses it:

The page's brighten.js, with brighten.wasmJavaScript
// brighten.js: brightens Asha's photo with brighten.wasm.const wasm = WebAssembly.instantiateStreaming(fetch("brighten.wasm")); document.querySelector("#brighten").addEventListener("click", async () => {  const { memory, brighten } = (await wasm).instance.exports;  const pixels = photo.data;  if (memory.buffer.byteLength < pixels.length) memory.grow(Math.ceil(pixels.length / 65536));  const bytes = new Uint8Array(memory.buffer, 0, pixels.length);  bytes.set(pixels); // copy the photo into Wasm's memory  brighten(0, pixels.length);  pixels.set(bytes); // copy it back  context.putImageData(photo, 0, 0);});
  • Line 2 starts loading brighten.wasm as soon as the page runs. The next section looks at instantiateStreaming().
  • Line 5 takes two of its exports, the things a module offers to JavaScript. memory is Wasm's linear memory: one long row of bytes. brighten is the function.
  • Line 7 makes the memory big enough for the photo. Wasm memory grows in pages of 65,536 bytes.
  • Line 8 makes a view: a Uint8Array laid over Wasm's memory, so JavaScript can write into it.
  • Line 9 copies the photo in. Line 10 brightens it. Line 11 copies it back out.
  • Line 12 draws the photo on the canvas. Every version does this.

The camera photo, timed on its own

The lab page timed the same work without the page around it. Each way ran in a fresh page. Its first run was its very first call. Then it ran again, and the lab kept the middle of those later runs.

The camera photo, each wayPop out in the code editor (opens in a new tab)JavaScript
// The camera photo (48 MB) on Asha's phone, copies included. Times in ms.const first = { js: 102.4, wasm: 162.9, simd: 60.7 };const later = { js: 95.4, wasm: 54.5, simd: 15.4 };for (const way of ["js", "wasm", "simd"]) {  console.log(`${way}: first ${first[way]} ms, later ${later[way]} ms`);}
  • Line 2 holds each way's first run. Line 3 holds its later runs. Both include the copies.
  • Lines 4 to 6 print js: first 102.4 ms, later 95.4 ms, then wasm: first 162.9 ms, later 54.5 ms, then simd: first 60.7 ms, later 15.4 ms.
Brightening the 48 MB camera photo on Asha's phone, to scale. JavaScript: first run 102.4 ms, later runs 95.4 ms. WebAssembly: first run 162.9 ms, later runs 54.5 ms, of which copying 9.4 ms. WebAssembly with SIMD: first run 60.7 ms, later runs 15.4 ms, of which copying 9.9 ms.brighteningcopying into Wasm memory and backJavaScriptfirst run102.4 mslater runs95.4 msWebAssemblyfirst run162.9 mslater runs54.5 msWebAssembly with SIMDfirst run60.7 mslater runs15.4 ms0 ms50 ms100 ms150 ms
From the course's lab page on Asha's phone. Shaded parts are the brightening itself. Plain parts are copying the photo into Wasm's memory and back. With SIMD, the copying took longer than the brightening.

The picture holds three findings.

Later runs, Wasm wins. Plain Wasm took 54.5 ms, a little over half of JavaScript's 95.4 ms. With SIMD it took 15.4 ms. Why it matters: work that repeats, like a preview under a slider, gets real gains.

The first run is different. Plain Wasm's first run took 162.9 ms. The brightening alone took 133.9 ms of that. Its later runs did the same brightening in 44.2 ms. So why was the first run three times slower?

Chrome's engine, V8, has two compilers for Wasm. You met them in the JavaScript lesson. Liftoff makes machine code very quickly, but the code is simple. TurboFan takes longer and makes faster code. V8's docs say it compiles each function with Liftoff the first time it is called. If the function runs a lot, TurboFan compiles it again in the background.

But a call that started on Liftoff's code finishes on it. Asha's tap was one long call. So all 12 million pixels ran on Liftoff's code. JavaScript's engine can move a running loop to faster code. Lesson Tiered compilation called that on-stack replacement. That's why JavaScript's first run, 102.4 ms, was close to its later ones.

Why it matters: a one-off job, like Asha's single tap, pays the first-run price. Time the first run, not only warm ones.

Small photos don't care. Here is a 200 by 150 thumbnail:

A thumbnail, each wayPop out in the code editor (opens in a new tab)JavaScript
// A 200 x 150 thumbnail, later runs, copies included. Times in ms.const later = { js: 0.24, wasm: 0.14, simd: 0.04 };console.log(`Slowest: ${Math.max(...Object.values(later))} ms`);
  • Line 2 holds the later runs of each way.
  • Line 3 prints the slowest: Slowest: 0.24 ms.

A frame lasts about 16.7 ms on a 60 Hz screen. A quarter of a millisecond is invisible. Why it matters: for small inputs, the simplest code wins. Like one cup, wash it by hand.

Read the table across. Each row is one version, for the camera photo on Asha's phone:

Each version, for the camera photo
VersionFirst runLater runsCopies, later runsBrighter photo on Asha's screen
JavaScript102.4 ms.95.4 ms.None: the pixels stay in JavaScript.147.5 ms after her tap.
WebAssembly162.9 ms.54.5 ms.9.4 ms.209.9 ms after her tap.
WebAssembly with SIMD60.7 ms.15.4 ms.9.9 ms.106.7 ms after her tap.

Pick a size to see every way. The second way comes up again in Crossing between JavaScript and Wasm:

Playground: one photo, four ways
Asha's phoneA phone camera's photo
WayFirst runLater runsCopying, later
JavaScript102.4 ms95.4 msnone
Wasm, one call per value2,637.7 ms2,608.8 msnone
Wasm162.9 ms54.5 ms9.38 ms
Wasm with SIMD60.7 ms15.4 ms9.88 ms
Try it yourself
A phone camera's photo: 4000 by 3000 pixels, 48,000,000 bytes

The quickest in later runs was WebAssembly with SIMD, one call, at 15.4 ms. Copying counts the trip into Wasm memory and back, which only the one-call Wasm ways make.

From the course's lab page in Chrome 157, on Asha's phone: this computer with its CPU slowed 4 times. Each way ran in a fresh page. The first run is its first call; later runs are the middle of several.

What kind of work is this?

Brighten fits Wasm well for three reasons:

  • It is one loop that runs millions of times.
  • It only does maths on numbers: bytes in, bytes out.
  • It never touches the page while it runs.

Surma, from Chrome's team, put it this way: "In general, JavaScript and WebAssembly can achieve the same peak performance." JavaScript only gets there on its engine's "fast path". Wasm's gain, he wrote, is "predictable performance". In his experience, he wrote, most performance problems on the web come from layout and paint. Wasm can't help with those.

Some jobs the browser already does in its own fast code. Google Sheets once ran regular expressions with a library compiled to Wasm. Switching to the browser's own RegExp made them nearly 100 times faster. Why it matters: if the browser has a built-in way, try it before you ship a module.

What a Wasm file costs to load

Ravi's module is tiny: 193 bytes. That's shorter than this paragraph. But real Wasm is often big, because it brings a whole library written in C, C++ or Rust.

Meera's next wish: make the photo's file smaller before it uploads. Squoosh, Google's image app, does this with MozJPEG, a JPEG encoder written in C. An encoder turns pixels into an image file. Squoosh ships MozJPEG as Wasm.

The course loaded that same encoder, from the jSquash package. Its .wasm file is 251,524 bytes. Compressed with brotli, it is 49,581 bytes on the network. With gzip, it would be 59,822.

There are two ways to load it:

Compile while it streams, or after it arrivesJavaScript
// Two ways to load a module: while it streams, or after it has all arrived.const streamed = await WebAssembly.compileStreaming(fetch("mozjpeg_enc.wasm")); const response = await fetch("mozjpeg_enc.wasm");const bytes = await response.arrayBuffer(); // wait for every byteconst compiled = await WebAssembly.compile(bytes);
  • Line 2 passes the fetch() straight to compileStreaming(). It starts compiling while bytes are still arriving. That's streaming compilation.
  • Lines 4 to 6 wait for every byte first, with arrayBuffer(). Only then does compile() start.

instantiateStreaming(), on the page, works the same way and also makes the instance. MDN calls it "the most efficient, optimized way to load Wasm code." The server must label the file application/wasm, or streaming refuses it.

Real-life analogyTranslating a cookbook as the pages arrive

A friend posts you a cookbook in a language you don't read, one page a day. You could wait for the whole book. Or you can check each page as it comes. Either way, you only translate a recipe on the day you cook it. Next month, the book is already on your shelf. But you still translate each recipe again.

In real life: The pages arriving one by one
In the browser: The .wasm file's bytes on the network
In real life: Reading each page as it comes
In the browser: Streaming compilation
In real life: Translating a recipe the day you cook it
In the browser: Compiling a function on its first call
In real life: The book kept on your shelf
In the browser: The file in the HTTP cache

The course loaded MozJPEG's module both ways, on Slow 4G and Asha's phone:

Ready, streamed or downloaded firstPop out in the code editor (opens in a new tab)JavaScript
// MozJPEG's module on Slow 4G, on Asha's phone. Times in ms after fetch().const streamed = { lastByte: 849.3, ready: 851.6 };const downloadedFirst = { lastByte: 850.5, ready: 853.3 };console.log(`Streamed: ready ${streamed.ready} ms`);console.log(`Downloaded first: ready ${downloadedFirst.ready} ms`);
  • Lines 2 and 3 hold, for each way, when the last byte arrived and when the module was ready.
  • Lines 4 and 5 print Streamed: ready 851.6 ms and Downloaded first: ready 853.3 ms.

Almost the same. The streamed module was ready 2.3 ms after its last byte. Downloaded first, compiling took 1.6 ms. Why so little? V8's docs say it doesn't compile any functions at first. Each one is compiled the first time it's called. Why it matters: on a slow network, the download is the cost. Keep the file small, send it compressed, and start it early.

So where did the compiling go? Into the first encode:

The first encode, and a repeat visitPop out in the code editor (opens in a new tab)JavaScript
// Encoding Asha's 1600 x 1067 photo as a JPEG. Times in ms.const firstVisit = { firstEncode: 479.5, secondEncode: 441 };const repeatVisit = { ready: 6.1, firstEncode: 477.9 };console.log(`First visit: ${firstVisit.firstEncode} ms, then ${firstVisit.secondEncode} ms`);console.log(`Repeat visit: ready after ${repeatVisit.ready} ms, first encode ${repeatVisit.firstEncode} ms`);
  • Line 2 holds the first visit's first and second encode of Asha's photo.
  • Line 3 holds a repeat visit: when the module was ready, and its first encode.
  • Lines 4 and 5 print First visit: 479.5 ms, then 441 ms and Repeat visit: ready after 6.1 ms, first encode 477.9 ms.

Chrome's trace shows what happened. During the first encode, V8 compiled 82 functions with Liftoff, each the first time it ran. That took 15.2 ms on the main thread. TurboFan compiled the 20 busiest again, in the background. So the second encode was 38.6 ms quicker.

On the repeat visit, the file came from the HTTP cache, the browser's store of files from earlier visits. No bytes crossed the network, and the module was ready after 6.1 ms. But the first encode was as slow as before.

Chrome can also keep compiled Wasm in a code cache. V8's docs set three conditions. The module must come from a streaming method. Only TurboFan's code is kept. And it is kept only once there is enough of it, so "small WebAssembly modules ... may never get cached."

In the course's recording, Chrome never wrote that cache for this module. The trace has no cache event. Every repeat visit compiled all 82 functions again. Why it matters: count on the HTTP cache to save the download. Don't count on the code cache.

MozJPEG's 251,524-byte module on Slow 4G and Asha's phone, to scale: the time until the module was ready, then the first encode of her photo. First visit, compiled while it streamed: ready after 851.6 ms, first encode 479.5 ms. First visit, downloaded first, then compiled: ready after 853.3 ms, first encode 476.9 ms. Repeat visit, from the HTTP cache: ready after 6.1 ms, first encode 477.9 ms.plain: getting the module ready · shaded: the first encodeFirst visit, compiled while it streamedready, 851.6 msencode, 479.5 msFirst visit, downloaded first, then compiledready, 853.3 msencode, 476.9 msRepeat visit, from the HTTP cache: ready after 6.1 msencode, 477.9 ms0 ms500 ms1000 ms
From the course's recording. Each bar puts two measured parts end to end: getting the module ready, then the first encode. The compile after the last byte was too short to draw.

Pick a visit to see all its numbers:

Playground: loading MozJPEG's module
MozJPEG's modulestreamed
Bytes transferred, headers included49,881
Last byte in, after fetch()849.3 ms
Module ready, after fetch()851.6 ms
Compiling after the last byte2.35 ms
Functions compiled during the first encode82
First encode479.5 ms
Second encode441 ms
Compiled code from Chrome's code cacheno
Try it yourself
First visit, compiled while it streams

The module was ready 851.6 ms after fetch() started. The first encode of Asha's photo then took 479.5 ms.

From the course's recording in Chrome 157, on Slow 4G and Asha's phone. Each visit is the middle of three. The server sent the file compressed with brotli.

Crossing between JavaScript and Wasm

Every time JavaScript calls into Wasm, it crosses a line. The place where the two hand values to each other is called the boundary. Numbers cross it easily. A photo can't be read across it: Wasm only reads bytes in its own memory.

Ravi's first try didn't move the photo at all. It called Wasm once for every value:

One call into Wasm per valueJavaScript
// One call into Wasm for every red, green and blue value.function brightenByCalls(pixels, liftOne) {  for (let i = 0; i < pixels.length; i += 4) {    pixels[i] = liftOne(pixels[i]);    pixels[i + 1] = liftOne(pixels[i + 1]);    pixels[i + 2] = liftOne(pixels[i + 2]);  }}
  • Line 2 takes the pixels and liftOne, the export from brighten.wasm.
  • Lines 4 to 6 call it for red, green and blue: three calls for every pixel.

The lab page timed it on the camera photo:

Many small calls, or one big onePop out in the code editor (opens in a new tab)JavaScript
// The camera photo again: one call per value, or one call for all. Times in ms.const calls = 36_000_000; // red, green and blue for 12 million pixelsconst perValue = 2608.8;const oneCall = 54.5;console.log(`One call per value: ${perValue} ms, about ${Math.round((perValue / calls) * 1e6)} ns a call`);console.log(`One call: ${oneCall} ms`);
  • Line 2 counts the calls: 36 million.
  • Lines 3 and 4 hold the two times, from later runs.
  • Line 5 prints One call per value: 2608.8 ms, about 72 ns a call. A nanosecond (ns) is a millionth of a millisecond. Line 6 prints One call: 54.5 ms.

72 ns is tiny. But 36 million of them add up to 2,608.8 ms. That's 27 times slower than plain JavaScript. Why it matters: call Wasm for a big job, not once per item.

Real-life analogyPassing plates through a serving hatch

Leaf and Kettle's kitchen has a small hatch. Ben can pass plates through it one by one. Each pass is quick, but 200 passes take all afternoon. Or he can load a tray and slide it through once. Loading the tray takes a minute, but it's one trip.

In real life: The hatch between the café and the kitchen
In the browser: The boundary between JavaScript and Wasm
In real life: Passing one plate at a time
In the browser: One call per value
In real life: Sliding a full tray through
In the browser: One call over data in Wasm's memory
In real life: Loading the tray first
In the browser: Copying the photo into Wasm's memory

One call is the tray. But the tray has to be loaded. Look back at the page's code: line 9 copies the photo into Wasm's memory, and line 11 copies it back. Here is what that cost with SIMD:

Copying against brightening, with SIMDPop out in the code editor (opens in a new tab)JavaScript
// Wasm with SIMD on the camera photo, later runs. Times in ms.const run = { copyIn: 4.8, work: 5.8, copyOut: 5.1 };const copies = Math.round((run.copyIn + run.copyOut) * 10) / 10;console.log(`Copying: ${copies} ms. Brightening: ${run.work} ms.`);
  • Line 2 holds a later run's three parts.
  • Line 3 adds the two copies, and rounds to a tenth.
  • Line 4 prints Copying: 9.9 ms. Brightening: 5.8 ms.

With SIMD, moving the photo took longer than brightening it. In the lab, the very first copy into newly grown memory took 25.0 ms. Why it matters: once the loop is fast, copies are the next cost. Keep big data on the Wasm side.

How? Load the photo into Wasm's memory once, when Asha picks it. An ImageData can be built on a view of that memory. Then putImageData() draws straight from it, and no tap has to copy the photo in or out.

Now try it in your own browser. This lab brightens a made-up photo with the course's own .wasm files:

Playground: brighten a photo in your browser
Your runs0 shown
    Try it yourself
    What your browser did

    Pick a way and a size, then press Brighten. Run each way twice: the first run can be slower.

    A made-up photo, brightened on this page with the course's own .wasm files. Your times depend on your device, and change from run to run.

    SIMD and threads: more work per step

    SIMD stands for Single Instruction, Multiple Data. It is one step that changes many values at once. Wasm's SIMD works on 128 bits at a time: 16 bytes, or four of Asha's pixels.

    Real-life analogyA cutter that stamps out four scones

    Meera cuts scones with a round cutter, one at a time. Then she buys a cutter with four rings on one handle. One press, four scones. Same dough, same shape, a quarter of the presses.

    In real life: A cutter with four rings
    In the browser: A 16-byte SIMD step: four pixels
    In real life: One press
    In the browser: One SIMD instruction
    In real life: The ring with no blade
    In the browser: The 0 for alpha: left as it is
    brighten-simd.ts: four pixels per stepAssemblyScript
    // brighten-simd.ts: the same, 16 bytes (four pixels) at a time.// 40 for red, green and blue, 0 for alpha, four times over.const lift = i8x16(40, 40, 40, 0, 40, 40, 40, 0, 40, 40, 40, 0, 40, 40, 40, 0); export function brightenSimd(start: usize, end: usize): void {  for (let i = start; i < end; i += 16) {    v128.store(i, i8x16.add_sat_u(v128.load(i), lift));  }}
    • Line 3 makes lift: 16 bytes in one value. Each pixel gets 40 for red, green and blue, and 0 for alpha.
    • Line 6 walks memory 16 bytes at a time.
    • Line 7 does everything. v128.load(i) reads 16 bytes at once. i8x16.add_sat_u adds each byte to the matching byte of lift. "sat" means it stops at 255 instead of wrapping around. v128.store writes all 16 back.

    In later runs on the camera photo, the SIMD loop took 5.8 ms. JavaScript's took 95.4 ms. That's 16 times faster, from one line.

    Two builds, and a check

    SIMD has to be turned on when you build:

    Building with and without SIMDShell
    npx asc brighten.ts -O3 -o brighten.wasmnpx asc brighten-simd.ts -O3 --enable simd -o brighten-simd.wasm
    • Line 1 builds the plain version.
    • Line 2 builds the SIMD version, with --enable simd. Without it, the compiler stops with "Feature 'simd' is not enabled."

    A browser without SIMD rejects the whole SIMD module, not just one function. V8's SIMD guide says to build both versions, then check which one the browser can run:

    Pick the build this browser can runJavaScript
    // Which build can this browser run? Ask it to check the bytes first.const simdBytes = await (await fetch("brighten-simd.wasm")).arrayBuffer();const { instance } = WebAssembly.validate(simdBytes)  ? await WebAssembly.instantiate(simdBytes)  : await WebAssembly.instantiateStreaming(fetch("brighten.wasm"));
    • Line 2 fetches the SIMD build's bytes.
    • Line 3 asks WebAssembly.validate() whether they make a module this browser understands. It checks the bytes without running them.
    • Line 4 uses those bytes if the answer is yes. Line 5 loads the plain build if it isn't.

    In Chrome 157, the lab page's checks said yes to SIMD, threads, WasmGC and JSPI.

    Threads: more hands on one photo

    SIMD does more per step. Threads do steps side by side, on more of the processor's cores. A core is one part of the CPU that runs one thread at a time.

    Lesson 27.4 moved work into a worker, a second thread. Wasm threads are workers that share one block of Wasm memory. web.dev lists the parts: workers, a shared WebAssembly.Memory, and atomic operations that let threads take turns safely.

    Real-life analogyFour bakers icing one big tray

    A party orders 400 iced buns on one big tray. One baker would take an hour. Four bakers stand round the tray, and each ices a quarter. Nobody carries buns anywhere. They all reach into the same tray.

    In real life: The big tray of buns
    In the browser: The photo in shared memory
    In real life: Each baker's quarter of the tray
    In the browser: Each worker's band of bytes
    In real life: The same recipe card for all four
    In the browser: The same compiled module
    In real life: The kitchen door locked to strangers
    In the browser: A cross-origin isolated page

    The shared build needs a few more flags:

    The shared buildShell
    npx asc brighten-simd.ts -O3 --enable simd,threads --importMemory --sharedMemory --maximumMemory 1024 -o brighten-simd-shared.wasm

    --enable threads turns threads on. --importMemory and --sharedMemory make the module use a shared memory that the page hands it. Here is the page:

    One photo, four workersJavaScript
    // One shared photo, four workers, a quarter each.const module = await WebAssembly.compileStreaming(fetch("brighten-simd-shared.wasm"));const memory = new WebAssembly.Memory({ initial: 733, maximum: 1024, shared: true });const workers = [0, 1, 2, 3].map(() => new Worker("brighten-worker.js"));workers.forEach((worker) => worker.postMessage({ module, memory }));const quarter = 48_000_000 / 4;workers.forEach((worker, i) => worker.postMessage({ fn: "brightenSimd", start: i * quarter, end: (i + 1) * quarter }));
    • Line 2 compiles the module once, on the page.
    • Line 3 makes a shared memory: 733 pages, room for the 48 MB photo. shared: true makes it a SharedArrayBuffer underneath.
    • Line 4 starts four workers.
    • Line 5 posts the module and the memory to each one. The page fetches and compiles the module once, and every worker reuses it. web.dev recommends this.
    • Lines 6 and 7 give each worker a quarter of the bytes.
    brighten-worker.jsJavaScript
    // brighten-worker.js: brightens its own band of the shared photo.let exports;onmessage = async ({ data }) => {  if (data.module) {    exports = (await WebAssembly.instantiate(data.module, { env: { memory: data.memory } })).exports;    postMessage("ready");  } else {    const start = performance.now();    exports[data.fn](data.start, data.end);    postMessage(performance.now() - start);  }};
    • Line 5 makes an instance from the module it was sent, using the shared memory.
    • Line 9 brightens its own band, from start to end. Line 10 sends back how long that took.

    Lesson 27.4 showed that DevTools can't slow a worker. So this test ran at this computer's full speed, on its 8 cores, without SIMD:

    Brightening the camera photo in shared Wasm memory without SIMD, later runs at this computer's full speed, to scale. The page's main thread: 10.6 ms. 1 worker: 10.7 ms. 2 workers: 5.5 ms. 4 workers: 2.8 ms.The page's main thread10.6 ms1 worker10.7 ms2 workers5.5 ms4 workers2.8 ms0 ms4 ms8 ms12 ms
    From the course's lab page, at this computer's full speed. Every bar ran the same brighten(), on the same shared photo. With four workers, each took a quarter of the photo, and the whole job took about a quarter of the time.
    Four workers against onePop out in the code editor (opens in a new tab)JavaScript
    // The camera photo, brightened by workers sharing its memory (no SIMD).// This computer's full speed: DevTools can't slow a worker. Times in ms.const workers = { one: 10.7, four: 2.8 };console.log(`Four workers took ${(workers.four / workers.one * 100).toFixed(0)}% of one worker's time.`);
    • Line 3 holds later runs with one worker and with four.
    • Line 4 prints Four workers took 26% of one worker's time.

    One worker's first run took 29.9 ms, against 10.7 ms later. That's Liftoff again. With SIMD, the job was already so short that threads saved under a millisecond: 1.41 ms on the main thread, 0.66 ms with four workers.

    Why it matters: threads pay for big work on phones with several cores. But they need a cross-origin isolated page, as lesson 27.4 showed. And each worker costs time to start.

    Playground: one photo, shared between workers
    The camera photo, 48 MBWithout SIMD
    Who brightened itFirst runLater runs
    The page's main thread10.8 ms10.6 ms
    1 worker29.9 ms10.7 ms
    2 workers5.56 ms5.53 ms
    4 workers3.04 ms2.82 ms
    Try it yourself
    Without SIMD: brighten() on the camera photo

    One worker's later runs took 10.7 ms. Four workers took 2.82 ms. A first run is slower: each worker's first call runs on Liftoff's code.

    From the course's lab page in Chrome 157, on this computer at full speed, with 8 processor cores: DevTools can't slow a worker. Later runs are the middle of 7.

    WasmGC and JSPI: other languages, and waiting

    Two newer features help whole apps move to Wasm. Neither makes Asha's brighten() faster. They change what is worth moving.

    WasmGC: the browser cleans up

    Languages like Java, Kotlin and Dart use a garbage collector: a helper that frees objects nobody uses any more. Plain Wasm has only bytes, so those languages had to pack their own collector into the .wasm file. WasmGC lets Wasm keep its objects in the browser's own collector instead. It's like a visiting chef using the café's bins, instead of bringing his own.

    That makes files smaller. V8's article gives one small test program as an example. Built with WasmGC, it was 2.3 KB. Built from C or Rust, it was 6.1 to 9.6 KB, because those bring their own memory code.

    And it can make apps faster. Google Sheets' calculation engine is written in Java. It ran in a worker, compiled to JavaScript. The team moved it to WasmGC. Their first version was about twice as slow as JavaScript. After a lot of tuning, it became about twice as fast. Why it matters: WasmGC is for bringing a whole app written in such a language. Your own JavaScript doesn't change.

    JSPI: Wasm that waits for a Promise

    Code in C or Rust often expects an answer right away: "read this file". Web APIs like fetch() answer later, with a Promise. JSPI, JavaScript Promise Integration, joins the two. Wasm pauses at a call that returns a Promise. It carries on when the Promise settles. Meanwhile, the page keeps running.

    It's like a cook who stops at "wait for the delivery", serves other customers, and picks the recipe up where she left it. Here is a module that needs this week's review count:

    reviews.ts: Wasm that needs a fetched numberAssemblyScript
    // reviews.ts: needs this week's review count, which the page fetches.@external("env", "reviewCount")declare function reviewCount(): i32; export function perTea(teas: i32): i32 {  return reviewCount() / teas;} // Asks for the count many times, to time one pause.export function countMany(times: i32): i32 {  let total = 0;  for (let i = 0; i < times; i++) total += reviewCount();  return total;}
    • Lines 2 and 3 declare an import: a function the page hands the module, called reviewCount.
    • Line 6 calls it and divides by the number of teas, as if the answer were already there.
    • Lines 10 to 14 call it many times. The lab used this to time one pause.
    The page: wrap the import and the exportJavaScript
    const reviewCount = new WebAssembly.Suspending(async () => {  const response = await fetch("review-count.json");  return (await response.json()).count;});const { instance } = await WebAssembly.instantiateStreaming(fetch("reviews.wasm"), { env: { reviewCount } });const perTea = WebAssembly.promising(instance.exports.perTea);console.log(await perTea(40));
    • Lines 1 to 4 wrap an async function in WebAssembly.Suspending. When Wasm calls it, Wasm pauses until its Promise settles.
    • Line 5 hands it to the module as its import.
    • Line 6 wraps the export with WebAssembly.promising(), so calling it returns a Promise.
    • Line 7 waits, and prints 100: 4,000 reviews over 40 teas.

    The lab page ran it on Asha's phone, with the server holding the count back like a slow API:

    What JSPI costPop out in the code editor (opens in a new tab)JavaScript
    // What the lab page saw on Asha's phone.const wait = { answer: 100, waitedMs: 303.7, timerRuns: 30 };const pauseUs = 0.375; // one pause and resume, in microsecondsconsole.log(`perTea(40) gave ${wait.answer} after ${wait.waitedMs} ms. A 10 ms timer ran ${wait.timerRuns} times meanwhile.`);console.log(`One pause cost ${pauseUs} µs.`);
    • Line 2 holds the answer, the wait, and how often a 10 ms timer ran during it.
    • Line 3 holds one pause and resume, in microseconds (µs): thousandths of a millisecond.
    • Line 4 prints perTea(40) gave 100 after 303.7 ms. A 10 ms timer ran 30 times meanwhile. Line 5 prints One pause cost 0.375 µs.

    The main thread stayed free while Wasm waited. A plain call to the same import cost 0.15 µs, so a pause adds about 0.2 µs. V8's article estimated about 1 µs per call in its own demo. Why it matters: ported code can wait for the network without freezing the page.

    Should this run in WebAssembly?

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

    Should this run in WebAssembly?
    • Brightening a 12-megapixel photo before upload
    • Encoding a photo as a JPEG file with MozJPEG
    • Resizing a photo's pixels with a smoothing filter
    • Brightening a 200 by 150 thumbnail
    • Writing a tea's rating into its row
    • Finding words in reviews with a regular expression
    • Waiting for this week's reviews to arrive
    • Sorting 40 teas by price
    Try it yourself
    0 of 8 correct

    Sort each job: can Wasm help, or should it stay in JavaScript?

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

    Common mix-ups

    • "WebAssembly is always faster than JavaScript." No. On Asha's first tap, plain Wasm took 209.9 ms and JavaScript took 147.5 ms.
    • "A faster loop makes the whole tap that much faster." No. The SIMD loop was 16 times faster, but the tap went from 147.5 ms to 106.7 ms. Copies and drawing stayed.
    • "Calling into Wasm is free." No. Each call is tiny, but 36 million of them took 2,608.8 ms.
    • "Streaming compilation hides a big download." No. On Slow 4G, both ways were ready after about 850 ms. The download was the cost.
    • "Chrome always keeps compiled Wasm for the next visit." No. It keeps only TurboFan's code, and only when there's enough. The recording never saw it happen.
    • "WasmGC and JSPI make my JavaScript faster." No. They help code from other languages run and wait in the browser. JavaScript doesn't change.

    Practice exercises

    Answer first, then use the hints.

    Exercise 1 · Warm-upOne bright pixel

    What does this program print?

    Starter codePop out in the code editor (opens in a new tab)JavaScript
    // brighten.js: adds 40 to each pixel's red, green and blue.
    function brighten(pixels) {
      for (let i = 0; i < pixels.length; i += 4) {
        pixels[i] += 40; // red: a Uint8ClampedArray stops at 255
        pixels[i + 1] += 40; // green
        pixels[i + 2] += 40; // blue
      }
    }
    
    const pixels = new Uint8ClampedArray([250, 10, 100, 255]);
    brighten(pixels);
    console.log(pixels.join(" "));

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

      Exercise 2 · Warm-upCount the crossings

      Here a JavaScript function stands in for the Wasm export. What does the program print?

      Starter codePop out in the code editor (opens in a new tab)JavaScript
      // One call into Wasm for every red, green and blue value.
      function brightenByCalls(pixels, liftOne) {
        for (let i = 0; i < pixels.length; i += 4) {
          pixels[i] = liftOne(pixels[i]);
          pixels[i + 1] = liftOne(pixels[i + 1]);
          pixels[i + 2] = liftOne(pixels[i + 2]);
        }
      }
      
      let count = 0;
      const liftOne = (value) => {
        count += 1;
        return Math.min(value + 40, 255);
      };
      brightenByCalls(new Uint8ClampedArray(8), liftOne);
      console.log(count);

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

        Exercise 3 · PracticeSIMD's first run on a web photo

        In the photo playground, pick the photo for the web. How long did Wasm with SIMD take on its first run, in ms?

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

          Exercise 4 · PracticeTwo workers with SIMD

          In the workers playground, pick With SIMD. How long did 2 workers take in later runs, in ms?

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

            Exercise 5 · ChallengeThe tap's task

            Open the course's trace of the JavaScript page in Chrome's DevTools. Find the task that ran Asha's tap. How long did it run, in ms?

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

              Exercise 6 · ChallengeHow long V8 waits before caching

              In V8's flag definitions for Chrome 157, how long does V8 wait with no new TurboFan code before it caches a module's code, in ms?

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

                Check your understanding

                For each question, ask what really took the time: the loop, the copies, the calls, or the download.

                The WebAssembly for performance quiz · 8 questionsScore: first tries count
                1. Question 1 of 8On Asha's first tap, brighten() in plain WebAssembly was slower than in JavaScript. What was the main reason?

                  Choose an answer to see the explanation.

                2. Question 2 of 8For a 200 by 150 thumbnail, what did WebAssembly with SIMD change for Asha?

                  Choose an answer to see the explanation.

                3. Question 3 of 8Calling liftOne() once for every red, green and blue value was far slower than JavaScript alone. Why?

                  Choose an answer to see the explanation.

                4. Question 4 of 8MozJPEG's module was ready almost as soon as its last byte arrived, streamed or not. Why didn't streaming save more?

                  Choose an answer to see the explanation.

                5. Question 5 of 8Why does the course build brighten twice, once with --enable simd and once without?

                  Choose an answer to see the explanation.

                6. Question 6 of 8What must a page have before its workers can share one WebAssembly.Memory?

                  Choose an answer to see the explanation.

                7. Question 7 of 8What is JSPI for?

                  Choose an answer to see the explanation.

                8. Question 8 of 8In this code, which line copies the photo into Wasm's memory?

                  Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                  const pixels = new Uint8ClampedArray(48_000_000);
                  const bytes = new Uint8Array(memory.buffer, 0, pixels.length);
                  bytes.set(pixels); // line 3
                  brightenSimd(0, pixels.length);
                  pixels.set(bytes); // line 5

                  Choose an answer to see the explanation.

                Key takeaways

                • Wasm can speed up a big loop over numbers. Small jobs, page updates and network waits don't get faster.
                • A function's first call runs on Liftoff's simple code. Time the first run, not just warm ones.
                • A .wasm file costs its download. Chrome compiles each function on first use, so streaming saves little.
                • The HTTP cache saves the download on repeat visits. Don't count on the code cache.
                • Every call crosses the boundary. Make one big call, and keep big data in Wasm's memory.
                • SIMD does 16 bytes per step. Build it twice and check with validate().
                • Threads share one memory between workers. They need a cross-origin isolated page.
                • WasmGC and JSPI help whole apps in other languages run and wait in the browser.

                Remember the one-liner.
                Wasm speeds up the loop, not the whole tap: measure the first run, cross once, and reach for SIMD.

                Sources

                The facts on this page come from these specs, docs and source files:

                Coming next: Fast event handlers. The next module looks at the code that runs when Asha taps, and how to make it answer quickly.

                CompleteFrontend Clear concepts. Working examples.