Memento Lab · Experiment 02 · · 27 min read
Hand-written WebAssembly, 5–60× faster than JavaScript: how, and the idea that beat it
We gave five jobs from our app a second, deliberate pass in hand-written WebAssembly: the password check, opening encrypted notes, typo-tolerant search, textures and a frosted-glass blur. Against JavaScript doing the same work they came out 5 to 60 times faster, and ahead of Rust too. An independent review caught a bug our 54,132 tests missed, and in search a better idea beat every language.
Note, 2026-10-31: the Argon2id numbers below are a benchmark at 3 passes over 256 MiB, the lab's test setting. They are not the settings Memento uses, which are listed on the security page.
In Experiment 01 we wrote fifteen jobs from Memento three ways: in JavaScript, in Rust compiled to WebAssembly, and in WebAssembly written by hand, instruction by instruction (no compiler: we wrote the low-level WebAssembly instructions ourselves). The hand-written versions rarely won. Against Rust they were never more than about 20% faster, and they only won clearly on one job, where no existing library used the processor's vector instructions (SIMD, which work on several numbers at once).
This experiment went back to the jobs where the hand-written version tied or lost, and gave each one a deliberate second pass: find out exactly where the time goes, change one thing, measure, keep it only if it helped. We call those versions v2. The first attempts are v1.
At a glance
Hand-written v2 (our second version of the WebAssembly we wrote by hand) against JavaScript doing the same job, with byte-identical output. Lower is faster; Chrome / WebKit. Each section below also compares with Rust, the usual low-level choice.
| Job | JavaScript | By hand v2 (hand-written WebAssembly) | v2 against JavaScript |
|---|---|---|---|
| Argon2id benchmark (256 MiB) | 10.7 / 3.4 s | 0.22 / 0.19 s | 18–50× faster |
| Opening 5,000 encrypted notes | 95.5 / 87.7 ms | 20.9 / 18.9 ms | 4.6× faster |
| Search, slowest keystroke at 5,000 notes | 276 / 428 ms | 9.1 / 6.7 ms | 30–64× faster |
| Stone texture, per pixel | 141 / 107 ns | 8 / 8 ns | 13–18× faster |
| Frosted-glass blur, per frame in the app | 20.8 ms, sky at 40 fps (Chrome) | 1.0 ms, sky at 60 fps | about 20× faster |
JavaScript here means what a JavaScript app would use: the pure-JavaScript noble libraries for the cryptography (the password-check figures are from Experiment 01), and the app's own code for search, textures and the blur.
On the server the choice is between compiled languages, so there the comparison is with C, Rust and Zig, each compiled to WebAssembly and run in Bun (each row from one run):
| On the server (Bun) | C (libsodium) | Rust | Zig | By hand v2 (hand-written WebAssembly) |
|---|---|---|---|---|
| Argon2id benchmark (256 MiB) | 390 ms | 403 ms | 422 ms | 189 ms |
| Opening 5,000 encrypted notes | 65.4 ms | 32.5 ms | — | 19.1 ms |
Two honest footnotes before the details. The texture win is mostly a better algorithm, and that algorithm also makes plain JavaScript beat Rust. And for search, a different design beat every version here, in plain JavaScript too. Both stories are below.
Five jobs, all from the app:
- The password check that runs when you unlock Memento.
- Opening encrypted notes, thousands at once.
- Search that forgives typos.
- Textures for a 3D scene, computed on your device.
- The frosted-glass blur behind one of the app's themes.
We also added a fourth language, Zig, and moved every timed run to a quiet machine. Two of the hand-written modules handle cryptography, so each also went to an independent reviewer that had not written it. One of those reviews found a real bug, and that is part of the story too.
The four worlds
"World" here means one way of writing the same job. Every world must produce exactly the same output.
How we measured
- Correct before fast. A version is only timed after it produces exactly the same bytes as the reference: libsodium for the cryptography, the app's own JavaScript for search and textures. The checks are large (54,132 for opening notes alone) and include tampered input, which every version must refuse.
- A quiet machine. Each timed run waits until the computer's load has stayed low for a full minute, and runs alone. Experiment 01 was measured while other work was running, so these numbers are lower in absolute terms. Compare versions within one chart, not across the two posts.
- A/B for every change. When we tried an optimisation, the old and new versions ran alternately in the same process, many times over, and we took the median. Any background noise then hits both sides equally, so a 5% gain is a real 5%.
- Two browser engines: V8 (Chrome, Edge) and JavaScriptCore (Safari, through Playwright's WebKit build), plus Bun for the jobs that can also run outside a browser. Firefox's test build still would not start on this machine.
How we built v2
The same five-step loop produced every v2. None of it is clever on its own. Together, it is the difference between guessing and knowing.
- Lock the answer first. Before any optimisation, each job got a fast correctness check: the reference output, edge cases, and for the cryptography every single changed bit. Any change could then be checked in seconds, so a wrong idea never got as far as a timing.
- Generate the code, don't type it. Nobody typed the v2 modules line by line. Small Python scripts write the WebAssembly text, and every idea is a switch in the script. The Argon2id generator has more than a dozen, for example
rot=1("rotate with shifts, not shuffles") orhyb=2("run two rows on the integer unit"). Trying an idea means flipping a switch and building a second module next to the first, with everything else identical. - Find out where the time goes. We timed each phase on its own, which showed for example that opening notes spent half its time in JavaScript, not in the cryptography. We read what the compilers produced (
wasm2watturns a module back into text), which is how we found that Rust's search loop had no branch where ours had one. And we checked what each engine does with an instruction: V8 rebuilds every byte shuffle from about ten ordinary instructions, while JavaScriptCore fuses a shift and an XOR into one. - Change one thing and measure it against the last version. Old and new modules ran alternately in the same process, many rounds, median of the ratios. A change stayed only if it helped across both engines. Plenty of ideas were dropped, and we list some of them below.
- Check again, then let someone else attack it. Every kept change went back through the full correctness check. The two cryptography modules then went to a reviewer who had not written them.
1 · The password check: Argon2id, about 2× libsodium
When you lock Memento and unlock it again with a PIN or password, the app has to check what you typed. It does not store the PIN. It stores a hash made with Argon2id, a function designed to be slow and memory-hungry on purpose: in the benchmark here, each check makes 3 passes over 256 MiB of memory, so someone who steals the hash cannot try millions of guesses quickly. The price is that you wait for one check every time you unlock. The number that matters is the time of one check.
The chart compares four versions of the same check:
- libsodium: the C library the app uses today, compiled to WebAssembly.
- Rust: the RustCrypto
argon2library, compiled to WebAssembly. - Zig std: the Argon2id in Zig's standard library, compiled to WebAssembly.
- By hand v2: our second hand-written version.
- Rust → wasm
- Zig → wasm
- Hand-written wasm
- C → wasm
One check, warm (the module is already loaded). All four versions produced the identical 32-byte hash.
Hand-written v2 takes about 190–215 ms, against 370–420 ms for the libraries: roughly half the wait at every unlock. Rust and Zig land on top of libsodium, which is what you get from a good library without writing any SIMD yourself.
What v2 changed. Argon2's inner step mixes 64-bit numbers with additions, multiplications and rotations, over and over. Each change below was kept only because an A/B run showed it helped:
- Two at once. A SIMD register holds two 64-bit numbers, so v2 runs two of the mixing steps (
G) side by side in one register (i64x2). - The right multiply, set up once. The mixing step multiplies the low 32 bits of two numbers. WebAssembly has an instruction that does exactly that (
i64x2.extmul_low_i32x4_u), but only if the inputs are arranged for it. v2 arranges them once per block instead of once per step. - Shifts instead of shuffles. A 64-bit rotation can be a byte shuffle or two shifts and an OR. The shifts were faster, because V8 rebuilds shuffle masks.
- Both units busy. The processor has a vector unit and an ordinary integer unit. v2 interleaves work for both, so neither waits.
One of those changes, before and after: a 64-bit rotation by 16 bits
;; v1: rotate each 64-bit lane right by 16 with a byte shuffle
(i8x16.shuffle 2 3 4 5 6 7 0 1 10 11 12 13 14 15 8 9 (local.get $x) (local.get $x))
;; v2: the same rotation as two shifts and an OR
(v128.or (i64x2.shr_u (local.get $x) (i32.const 16))
(i64x2.shl (local.get $x) (i32.const 48)))
Both give the same bits. The shuffle is one instruction in the text, but V8 turns it into about ten; the shifts stay three.
What was tried, and what stayed
| Idea | Result |
|---|---|
Two G steps interleaved, sharing one data rearrangement per multiply pair |
kept |
| Rotations with shifts instead of shuffles | kept |
| Every loop fully unrolled, with constant offsets | kept |
| Two rows per step, so the processor has two independent chains of work | kept |
| Two of the eight rows on the ordinary integer unit, interleaved with the SIMD rows | kept, the largest single gain |
| Touching the next block's memory early, while the current one is still computing | kept |
| A rotation by 63 bits written as an addition (x + x) | dropped, slower in V8 |
| Three other variants of the row and column code | dropped, no gain |
Rust and Zig did not find this on their own. With their SIMD option turned on, both got slower: WebAssembly has no vector rotate and no 64-bit "multiply-high", so the compilers' vector code kept going back through memory.
Memory never shrinks
A WebAssembly module's memory can grow but never shrink. After one 256 MiB benchmark check, a libsodium instance keeps holding 258 MB for as long as it lives, and Zig's default allocator kept 513 MiB because it rounds sizes up to powers of two. The fix is not a cleverer allocator but a shorter life: run the check in a worker with a fresh module, and let the worker go when it is done. Measured in Bun, that uses 273 MB during the check and is back to 3 MB above where it started once the worker has exited.
The review
An independent reviewer compared the hand-written module with libsodium, noble and RustCrypto on 10,172 inputs, and every hash matched. The problems were in the JavaScript around the module, not in the module: a text password was turned into the wrong bytes before hashing, nothing checked that the settings were sane, and nothing erased the memory afterwards. All three are fixed in the library version, and a second review of that version found no High or Medium issues in 22,170 more cases.
2 · Opening 5,000 notes in one call
Every note in Memento is encrypted on your device, and the server only stores the result. Each note has its own key, and that key is itself encrypted ("wrapped") with your device's key. So opening a note takes four steps:
- Decode the stored text (base64) back into bytes.
- Unwrap the note's key with your device key (the XSalsa20-Poly1305 cipher).
- Decrypt the title (XChaCha20-Poly1305).
- Decrypt the content (the same cipher).
Every step also checks a tag, a short checksum that proves nobody changed the data. For someone with a few thousand notes, that is a few thousand rounds of this before the list can show. The number that matters is the time to open the whole list.
In Experiment 01 we learned that libsodium's cost was mostly the trips between JavaScript and WebAssembly: five calls per note, each copying data in and out. The chart compares:
- libsodium: today's code, one note at a time.
- Rust + simd128 and Zig + simd128: one call per note, with each language's vector instructions turned on.
- By hand, per note: the hand-written module, one call per note.
- By hand, one call: the same module, given the whole list in a single call.
- Rust → wasm
- Zig → wasm
- Hand-written wasm
- C → wasm
5,000 notes, warm. Every version returned exactly libsodium's output for every note.
Opening 5,000 notes drops from 90 ms (Chrome) and 75 ms (WebKit) with libsodium to 21 and 19 ms with one hand-written call: 3.4–4.3× faster, and 1.7× faster than Rust.
How. The hand-written module does all four steps in one pass: base64 with SIMD, the ChaCha20 cipher four blocks at a time, the tag check in two SIMD lanes, a comparison that takes the same time whether the tag is right or wrong (so its timing reveals nothing), and an erase of every key after every note. It is 15 KB. Of its 21 ms, only about 11 are the cryptography; the rest is JavaScript turning bytes into text. That is why handing over the whole list in one call mattered as much as the faster code.
What each change was worth, measured one at a time
| Change | Time saved |
|---|---|
| ChaCha20 four blocks at a time with SIMD | 20–27% |
| Computing the first keystream blocks before the tag check finishes | 10% |
| base64 decoding with SIMD | 12% |
| Counting each decrypted text's length with SIMD, so JavaScript can turn all of it into strings in one go | 17–20% |
| The tag calculation in two SIMD lanes | 3–4% |
| The whole list in one call instead of one call per note | 21% (26.5 → 20.9 ms in Chrome) |
A faster tag calculation with bigger pieces was not possible: it needs 64 × 64 → 128-bit multiplications, which WebAssembly does not have.
The bug the tests missed
Before we timed it, the module passed 54,132 checks of our own: official test vectors from the cipher specifications, every single changed bit, every cut-short input. Then the independent reviewer looked at one specific step, the tag calculation, and built inputs aimed at it. They found a wrong tag.
The tag is computed with large numbers split into pieces ("limbs"). When the two SIMD lanes finished a message, one piece could grow slightly too big, and the final "carry" into the next piece started one piece too late. It only happened for messages of 128 bytes or more whose length was a multiple of 16, about 6 in 20,000 targeted tries, and never for the shape of data the app stores in a note. Repeating one of the specification's own test vectors twice was enough to show it.
The fix was one carry in the right place. The lesson is the one people repeat about cryptography and still underrate: a big test suite written by the author tests what the author thought of. The reviewer's input, the doubled test vector and a 200,000-case search for the same pattern are now part of the checks, and libsodium stays as the fallback.
3 · Search: the mispredicted branch, then a better idea
Memento searches your notes on your device, because the server only has ciphertext. The search forgives typos: type "pasword" and it still finds notes that say "password". To decide that, it measures how many single-letter changes turn one word into the other (the edit distance) and accepts one or two. Doing that for every word of every note, on every key you press, is the job here.
The number that matters is the slowest keystroke: search runs again on each key press, so the slowest one is the pause you feel while typing. The worst case is a word no note contains, because then every word of every note has to be checked.
The chart has six versions of the same search:
- JS, today: the app's current code, which redoes its preparation on every search.
- JS, tidied: the same algorithm, with the notes prepared once instead of on every keystroke.
- Rust: a smarter algorithm (Myers' bit-vectors) that compares a whole word at once using the bits of a 64-bit number, compiled from Rust.
- Zig @Vector: the same algorithm in Zig, checking two words at once with portable SIMD.
- By hand v1: our first hand-written WebAssembly version of the bit-vector algorithm. It lost to Rust.
- By hand v2: the second pass, described below. It is the fastest.
- JavaScript
- Rust → wasm
- Zig → wasm
- Hand-written wasm
5,000 notes of about 200 words each, slowest keystroke while typing "kubernetes". Two runs with the same notes and method. Every version found exactly the same notes as today's code on every keystroke.
At 5,000 notes today's code pauses for 276 ms (Chrome) and 428 ms (WebKit) on a single key press, long enough to feel the typing stick. Hand-written v2 takes 9.1 and 6.7 ms, well inside one 16.7 ms frame: 30–64× faster than today, and 1.8–2.1× faster than Rust.
Why v1 lost: one if. For each letter, v1 updated a running score with an if/else whose direction depends on the data. The processor guesses the direction of every branch in advance, and here it guessed wrong constantly, throwing work away each time. LLVM had compiled Rust's version of the same loop into two select instructions, which pick a value without branching. Removing the branch alone brought the hand-written module level with Rust.
v1's inner loop, the branch that cost it the race (from the module)
(if (i64.ne (i64.and (local.get $ph) (local.get $high)) (i64.const 0))
(then (local.set $score (i32.add (local.get $score) (i32.const 1))))
(else (if (i64.ne (i64.and (local.get $mh) (local.get $high)) (i64.const 0))
(then (local.set $score (i32.sub (local.get $score) (i32.const 1)))))))
Every letter of every word runs this. Whether the score goes up, down or stays depends on the text, so the processor cannot predict it.
v2 does not keep a running score at all. The bit-vectors already hold the answer, so the distance is read once at the end of the word: the word's length, plus the number of 1 bits in one vector, minus the number in the other (i64.popcnt). No branch is left in the loop.
What v2 added. Three more changes, each kept only after an A/B run showed a gain:
- Words grouped by length. Words are sorted by length in small batches, so every batch runs the same fixed number of steps with no per-word checks (+30–45%).
- Three words at once. Two words run side by side in one 128-bit SIMD register (
i64x2) while a third runs in an ordinary 64-bit register, so the vector unit and the integer unit are both busy. - Less work per step. The final distance comes from counting bits once at the end instead of updating a score every step (+10%), and words of the wrong length are skipped before any letter is compared.
Then an idea beat all of it
When we turned the search into a reusable library, we changed the design. All versions above check every word of every note. But most notes share most of their words. So the library keeps each distinct word once, grouped by length, with the list of notes that contain it. A search only compares the query with words of a similar length, and each match brings its whole list of notes at once. The chart compares:
- Per-note kernel v2: hand-written v2 from above, checking every word of every note.
- Index, wasm: the word index, with the comparison in WebAssembly.
- Index, JavaScript: the same word index in plain JavaScript.
- JavaScript
- Hand-written wasm
A different set of 5,000 notes from the chart above (generated, so it can be shared), slowest keystroke across four typing scenarios. Every version found the same notes on every keystroke.
The slowest keystroke drops to about one millisecond, and the plain JavaScript index is as fast as the WebAssembly one. Once the algorithm does a hundredth of the work, the language stops mattering. For Memento, that means faster search can ship as plain TypeScript.
4 · Textures: 6–9× Rust, mostly by changing the maths
Memento has a 3D scene in development: a vault door set in a stone wall. Its surfaces are not photographs. They are procedural textures: images computed on your device from a formula, so nothing has to be downloaded and every pixel can be as sharp as the screen needs. Each surface needs four images that a 3D renderer combines: the colour, the small bumps that catch the light, how rough or shiny each point is, and soft shadow in the cracks.
The formula is value noise: random-looking but smooth patterns, made by hashing the coordinates of a grid and blending between grid points, several layers at different sizes on top of each other. For a 2048 × 2048 stone that is about 12 noise lookups and 48 hash calculations for each of 4 million pixels, before the scene can appear. Two generators are measured here: the stone wall and the vault face, the door's dial with its numerals.
The chart compares:
- JavaScript: the app's own generator.
- Rust: the same code in Rust, with one fix from Experiment 01 (an integer remainder instead of a slow floating-point one).
- By hand v1: Experiment 01's hand-written SIMD version.
- By hand v2: the second pass, described below.
- JavaScript
- Rust → wasm
- Hand-written wasm
Warm runs at the full size the scene uses. Every version produced exactly the same pixels as the JavaScript reference, in both engines.
A stone that took 0.6 s in JavaScript and 0.3 s in Rust now takes 35–40 ms by hand: the difference between a scene that pops in and one that is simply there. Hand-written v2 is 6–9× faster than Rust and 10–17× faster than JavaScript.
Most of the gain is not WebAssembly. The noise at a pixel depends on its row and its column, and it can be split into a part that depends only on the row and a part that depends only on the column. v2 computes each part once, into small tables, so the inner loop over pixels has no hashing, rounding, wrapping or division left at all. The rest is instruction-level: working one noise layer at a time over runs of pixels that share a grid cell (−19 to −32%), reusing sums between neighbouring columns for the bump image, writing 8 bytes at a time, and one call per row so V8 can optimise the code between calls.
The idea in JavaScript, simplified to one noise layer
// Before: every pixel hashes its four surrounding grid points
for (let y = 0; y < h; y++)
for (let x = 0; x < w; x++)
out[y * w + x] = smoothNoise(x / cell, y / cell); // 4 hashes, floor, wrap, blend
// After: everything that depends only on y is made once per row,
// everything that depends only on x once per image
const col = columnTables(w, cell); // grid index and blend weight for each x
for (let y = 0; y < h; y++) {
const row = rowTables(y, cell); // the two grid rows' hashed values for this y
for (let x = 0; x < w; x++)
out[y * w + x] = blend(row, col, x); // table reads and multiplies only
}
Same pixels, byte for byte. The real generator does this for every layer, then works through runs of pixels that share a grid cell.
What else was tried
| Change | Result |
|---|---|
| Row and column tables (above) | kept, most of the gain |
| One noise layer at a time over runs of pixels that share a grid cell | kept, 19–32% faster |
| Skipping the bump calculation's three divisions where the surface is flat | kept, 5% |
| Row values kept in local variables, vector constants set once | kept, 5–6% |
| One call per row, so V8 optimises between calls | kept; the first texture 1.6–2× faster, warm unchanged |
| All noise layers in one loop | dropped, ±2–4% |
| Four pixels at a time in 32-bit floats | not allowed: the reference computes in 64-bit, and the output must match exactly |
The same tables work in JavaScript. Our library's JavaScript fallback uses them, and in Node it renders a 1024 × 1024 stone in 32 ms against 188 ms for the original per-pixel code. Rust restructured the same way would gain too, so a fair language-against-language comparison for this job is still to be done.
At every size. Experiment 01 timed the stone at every size from 64 to 4,096 pixels; we repeated that with v2 included, one size and one browser at a time so the machine stayed quiet. The cost per pixel is flat for every version from 512 pixels up: v2 needs about 8 nanoseconds per pixel, Rust 58–72 and the app's JavaScript 107–162. On small images v2's lead shrinks a little (14–16 ns at 64 pixels), because building its tables is a bigger share of a small job.
Two more things showed up:
- The better maths beats Rust even in plain JavaScript. The JavaScript version with the same row and column tables needs 24–30 nanoseconds per pixel: 2.4× faster than Rust running the original algorithm.
- Split long jobs into calls. V8 only switches a WebAssembly function to its optimised code between calls. v2 makes one call per row, and its first 2048 stone takes 40 ms in Chrome, 1.2× its warm time. The same code as one long call takes 78 ms, 2.3× its warm time. Rust shows the same: 467 ms in one call, 315 ms in bands of 64 rows.
Every size, both engines: nanoseconds per pixel, warm (Chrome / WebKit). Lower is faster.
| Version | 64 px | 256 px | 512 px | 1024 px | 2048 px | 4096 px |
|---|---|---|---|---|---|---|
| JavaScript (the app's code) | 150 / 120 | 143 / 154 | 141 / 158 | 140 / 145 | 141 / 107 | 141 / 149 |
| Rust | 73 / 59 | 72 / 59 | 76 / 63 | 72 / 58 | 72 / 58 | 72 / 59 |
| By hand v1 (SIMD) | 84 / 71 | 82 / 72 | 98 / 90 | 82 / 72 | 82 / 72 | 82 / 72 |
| By hand v2 (hand-written WebAssembly) | 14 / 16 | 10 / 10 | 9 / 9 | 8 / 9 | 8 / 8 | 8 / 8 |
| JavaScript with v2's tables | 41 / 31 | 32 / 25 | 32 / 25 | 31 / 24 | 30 / 24 | 42 / 24 |
5 · Zig, the fourth world
Zig is a systems language that, like Rust, compiles through LLVM, the same compiler back end. For the jobs here it used its own standard library (Argon2id, the note ciphers, base64) or a direct translation of the Rust search loop.
- Equal to Rust, not better. The translated search loop ran within 1–4% of Rust; opening notes was within 10–15%.
- The differences come from the libraries. Zig's tag calculation uses 128-bit multiplications, which WebAssembly does not have, so each one becomes a helper function call. Its ChaCha20 cipher uses SIMD on x86 and ARM, but not on WebAssembly.
- Smaller modules. 2–3× smaller than Rust for the same job: 20.9 KB against 61.4 KB for Argon2id.
- Portable SIMD that works. Zig's
@Vectortype let the search loop check two words at once and run 1.3–1.4× faster than Rust, without writing any WebAssembly by hand.
6 · The blur and the spores, in the real app
Night Garden is one of Memento's themes: a night sky with drifting auroras behind the app, and frosted-glass bars (the top bar and the sidebar) that show a blurred copy of the sky moving behind them. The browser's own CSS blur kept the page busy, so the theme draws the sky in a background worker instead and blurs it there, on every frame. Floating spores drift over the meadow at the bottom.
We measured it inside the real app, signed in, with the theme switched on and only the blur changed between runs. The sky is drawn at half resolution: 720 × 450, so 324,000 pixels to blur every frame. Each version first had to produce exactly the same bytes as the JavaScript blur, at two blur radii, on the real sky and on random pixels. All did.
The worker's frame, Chrome (four rounds) / WebKit (two rounds). Lower is faster.
| Blur | Whole frame | Blur alone | Sky frame rate | Worker busy |
|---|---|---|---|---|
| JavaScript (the theme's own blur) | 23.4 / 23–24 ms | 20.9 / 19 ms | 40 fps | 94% |
| By hand v1 (Experiment 01) | 5.4 / 7 ms | 2.4 / 2 ms | 60 fps | 32% |
| Rust | 5.5 / 7–10 ms | 2.5 / 2–3 ms | 60 fps | 33% |
| By hand v2 (hand-written WebAssembly) | 4.3 / 6 ms | 1.0 / 1 ms | 60 fps | 25% |
| No blur at all, for scale | 3.5 / 5 ms | — | 60 fps | 21% |
WebKit rounds its timers to whole milliseconds, so its column is coarse. Busy is the share of time the worker was working, Chrome.
Why JavaScript takes 20 ms. The blur is three box blurs in a row, each one a horizontal sweep and a vertical sweep, over four colour channels. For 324,000 pixels that is about 6 million read-add-write steps per frame. JavaScript does them one byte at a time, checks every array access, and its vertical sweep walks down columns, so each read lands a whole row further on in memory and the processor's cache keeps missing. v2 adds 16 bytes per instruction with SIMD, and works through a few rows at a time instead of down columns.
Why the page did not freeze anyway. All of this runs in the worker, so the page itself stayed at 60 fps with no frame over 20 ms, in every version and both engines. What JavaScript lost was the sky: the worker was busy 94% of the time and could only finish 40 frames a second. That still costs battery, and it shows as a stutter in the glass.
The start-up cost. Each aurora is blurred once, when the theme starts. In JavaScript that was one 90–110 ms block of work in the worker; v2 does it in 3.5–8.5 ms, Rust in 15–21 ms.
Doing less work beat doing it faster
With the kernel at 1 ms, the next questions were about what to blur at all.
| Change, on top of v2 (Chrome) | Pixels blurred per frame | Whole frame | Blur alone |
|---|---|---|---|
| v2, whole sky | 324,000 | 4.3 ms | 1.0 ms |
| Only the area behind the bars, plus a margin | 112,311 | 2.9–4.3 ms | 0.3–0.4 ms |
| A quarter-size copy of the sky, blurred and scaled back up | 81,000 | 3.5–4.3 ms | 0.2–0.3 ms |
| The sky drawn by the graphics card, only the bar areas blurred | 112,311 | 1.5–3.5 ms | 0.4–0.8 ms |
Blurring only behind the bars is the cleanest win: most of the sky is never behind glass. The margin is three times the blur radius, which is how far three box blurs can reach, so the pixels under the bars are byte-identical to blurring the whole sky. The quarter-size copy is not byte-identical, though we could not see a difference in screenshots. Letting the graphics card draw the sky gave the lowest frame time in Chrome: the worker was busy only 10–11% of the time, against 94% with JavaScript.
In WebKit these extra changes did not help: the cropped and quarter-size versions took 6–7 ms per frame, the same as v2 on the whole sky, and the graphics-card sky 4–5 ms. Reading pixels back costs more there, which eats the saving.
The spores: too small to matter
The spores already move on the graphics card: a small WebGL shader works out each spore's position and glow from the time, and the processor only sends the time once per frame. We wanted to know whether doing that maths on the processor instead would cost anything, so we wrote it three ways and ran each alongside the shader: JavaScript with Math.sin, JavaScript with a faster approximate sine, and hand-written SIMD WebAssembly. Timed in a loop, the WebAssembly version was the fastest: for the 53 spores, 0.09 µs per frame, against 0.16 µs for the fast sine and 0.6 µs for Math.sin in Chrome (0.08, 0.16 and 0.23 µs in WebKit). Sending the frame to the graphics card takes about 1 µs.
A frame lasts 16,700 µs. Every version is well under a thousandth of it, and the main thread's load and the frame rate did not change between versions, or even with the spores switched off. So the spores stay as they are. A faster kernel only matters when the job is a real share of the frame, and here it is not.
Switching off the other moving parts (the lantern's glow, the fireflies, the dust) brought the main thread's share down from about 15% to 10–11% in Chrome, and changed nothing in the worker. The blur was the one cost worth moving.
7 · The page, not the kernel
Some of the biggest wins this round had nothing to do with WebAssembly. They came from measuring the app's own pages and asking why they paused. A "freeze" or "pause" below is the longest time the page could not respond to you; one frame is 16.7 ms.
| Job | Before | After | What changed |
|---|---|---|---|
| Importing your own export of 5,000 notes | 352 ms (Chrome), 575 ms (WebKit) | 57 ms, 28 ms | a faster zip library (fflate instead of JSZip), and the file is read once instead of twice |
| Exporting 5,000 notes | one 675 ms freeze (Chrome), 9.1 MB file | longest pause 3 ms, 4.4 MB file | the zip is built and compressed in a background worker |
| Opening 5,000 notes | one 91 ms freeze (Chrome) | longest pause 9.8 ms | notes are decrypted in slices of up to 100 notes or 8 ms, letting the page draw in between |
| PDF of a short Japanese note | 482 KB, 21.5 ms | 17 KB, 1.2 ms | the embedded font is cut down to the characters the note uses |
| Landing page intro, Chrome with a 4× slower CPU | 2.2–2.5 s of style work in the first 3 s | 0.48–0.56 s | the meadow is drawn on a canvas, and each plant animates on its own |
Three findings from that table are worth spelling out.
scheduler.yield()is not the best way to let the browser draw. It is the newer API made for exactly this. But when we decrypted in slices withscheduler.yield()between them, Chrome only drew the page every 50–117 ms. Posting a message to ourselves through aMessageChannelbetween slices let it draw every frame.- One animated CSS variable can restyle a whole page. The landing page grew its plants by animating one inherited CSS custom property per plant. Every element inside a plant inherits it, so on every frame the browser recalculated the style of about 6,700 elements. Giving each shape its own animation, and drawing the meadow on a canvas, left about 160 per frame.
- The font is most of the PDF. A PDF must carry the font for any character outside basic Latin. Every Japanese or Cyrillic PDF embedded the whole font, about 480 KB, even for one line of text. A subsetting library keeps only the characters used, and the pages look pixel-for-pixel the same.
8 · From lab to library
Each fast module above is now a small library with a typed API, an automatic fallback (plain JavaScript, or libsodium and noble for the cryptography) for browsers that cannot run it, its full test suite, and a build check that rebuilds the .wasm from source and fails if a single byte differs. "Mutants" below are copies of the code with a bug planted on purpose; a good test suite catches every one.
| Library | Size | Tests | Review |
|---|---|---|---|
| Fuzzy word index | 2.9 KB | 45 tests, 173,791 assertions | not cryptography |
| Batch decrypt (secretbox, XChaCha20-Poly1305) | 18.9 KB | 72 tests; caught 58 of 60 mutants (the other 2 change nothing) | independent: no bug in the module, five small fixes in its JavaScript |
| Argon2id | 52.5 KB | 217 tests; caught 67 of 67 mutants | independent: no High or Medium issues, five small fixes in its JavaScript |
| Procedural textures | 29.1 KB | 77 tests | not cryptography |
| Box blur | 2.4 KB | 57 tests | not cryptography |
The cryptography libraries are labelled experimental. Reviewed in our lab is not the same as audited by a firm that does this for a living, and we would not ask anyone to treat it as such.
Rules of thumb, revised
- Change the algorithm before the language. The two largest wins here, the word index and the split noise, also made plain JavaScript fast.
- Hand-written WebAssembly wins by doing less per element, not by being "closer to the metal": no unpredictable branches, fewer passes over memory, shifts instead of shuffles, and the vector and integer units busy at the same time.
- Cross the boundary once. Opening notes with one call per note cost as much as the cryptography itself.
- WebAssembly memory never shrinks. Run big jobs in a worker you can throw away.
- Look at the page before the kernel. A CSS variable, a font and a zip library cost more than any loop we rewrote.
- Have someone else attack your cryptography. 54,132 of our own checks passed a wrong tag; a reviewer aiming at one step found it.
- Rust and Zig are interchangeable here. Choose by libraries and module size, not by speed.
What comes next
This is Memento Lab's second experiment, and like the first it was measured in a lab, on copies of the app's code. The next step is different: these changes go into Memento itself, and we measure the real app before and after. That will be a report, not an experiment.
Versions and method
Apple M4. Chrome for Testing (V8) and Playwright's WebKit build (JavaScriptCore), headless, cross-origin isolated; Bun 1.4.4 and Node 26.12.1. Rust 1.99 (wasm32-unknown-unknown), Zig 0.17.0 (wasm32-freestanding, ReleaseFast), wabt 1.0.42, libsodium-wrappers 0.8.4, @noble/ciphers 1.3.0, @noble/hashes 1.8.0, fflate 0.8.3, JSZip 3.10.1. Timed runs waited for a one-minute load average under 5; optimisation decisions used interleaved A/B in one process. Argon2id: median of warm checks in fresh workers. Opening notes: median of 5 fresh workers. Search: the 500-note set repeated to 5,000, each keystroke replayed in order, median of 7. Textures: median of 9 in a warm worker.