Back to blog

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.

JavaScript
The app’s own code as the reference, and tidied or restructured versions of it.
V8 · JavaScriptCore
Rust → wasm
RustCrypto and straight ports, with and without simd128.
rustc 1.99 · wasm32-unknown-unknown
Zig → wasm
New this time. Zig’s standard library for the crypto, a port of the Rust loop for search, plus portable @Vector SIMD.
zig 0.17.0 · wasm32-freestanding
Hand-written wasm
Version two of each module, generated by small Python scripts and changed only when an interleaved A/B showed a gain.
.wat → wat2wasm 1.0.42
C → wasm
libsodium 0.8.4, what the app ships today, and the reference every crypto result must match.

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.

  1. 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.
  2. 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") or hyb=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.
  3. 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 (wasm2wat turns 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.
  4. 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.
  5. 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 argon2 library, compiled to WebAssembly.
  • Zig std: the Argon2id in Zig's standard library, compiled to WebAssembly.
  • By hand v2: our second hand-written version.
One Argon2id check, warm
Argon2id benchmark: 3 passes, 256 MiB · median in a warm worker · lower is faster
  • Rust → wasm
  • Zig → wasm
  • Hand-written wasm
  • C → wasm
Chrome · V8
libsodium
Rust
Zig std
by hand v2
WebKit · JavaScriptCore
libsodium
Rust
Zig std
by hand v2
Bun · JavaScriptCore
libsodium
Rust
Zig std
by hand v2

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:

  1. Decode the stored text (base64) back into bytes.
  2. Unwrap the note's key with your device key (the XSalsa20-Poly1305 cipher).
  3. Decrypt the title (XChaCha20-Poly1305).
  4. 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.
Opening 5,000 encrypted notes, warm
base64, key unwrap and two decryptions per note · lower is faster
  • Rust → wasm
  • Zig → wasm
  • Hand-written wasm
  • C → wasm
Chrome · V8
libsodium
Rust + simd128
Zig + simd128
by hand, per note
by hand, one call
WebKit · JavaScriptCore
libsodium
Rust + simd128
Zig + simd128
by hand, per note
by hand, one call

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.
Typo-tolerant search, slowest keystroke
5,000 notes, typing a word no note contains · lower is faster
  • JavaScript
  • Rust → wasm
  • Zig → wasm
  • Hand-written wasm
Chrome · V8
JS, today
JS, tidied
Rust
Zig @Vector
by hand v1
by hand v2
WebKit · JavaScriptCore
JS, today
JS, tidied
Rust
Zig @Vector
by hand v1
by hand v2

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.
The same search with a word index
5,000 synthetic notes, slowest keystroke over four typing scenarios · lower is faster
  • JavaScript
  • Hand-written wasm
Chrome · V8
per-note kernel v2
index, wasm
index, JavaScript
WebKit · JavaScriptCore
per-note kernel v2
index, wasm
index, JavaScript

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.
Procedural textures at 2048, warm
stone wall 2048 × 2048, vault face 2048 × 1024 · four images each · lower is faster
  • JavaScript
  • Rust → wasm
  • Hand-written wasm
Stone · Chrome
JavaScript
Rust
by hand v1
by hand v2
Stone · WebKit
JavaScript
Rust
by hand v1
by hand v2
Vault face · Chrome
JavaScript
Rust
by hand v1
by hand v2
Vault face · WebKit
JavaScript
Rust
by hand v1
by hand v2

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 @Vector type 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 with scheduler.yield() between them, Chrome only drew the page every 50–117 ms. Posting a message to ourselves through a MessageChannel between 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

  1. Change the algorithm before the language. The two largest wins here, the word index and the split noise, also made plain JavaScript fast.
  2. 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.
  3. Cross the boundary once. Opening notes with one call per note cost as much as the cryptography itself.
  4. WebAssembly memory never shrinks. Run big jobs in a worker you can throw away.
  5. Look at the page before the kernel. A CSS variable, a font and a zip library cost more than any loop we rewrote.
  6. Have someone else attack your cryptography. 54,132 of our own checks passed a wrong tag; a reviewer aiming at one step found it.
  7. 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.