Memento Lab · Experiment 01 · · 36 min read
JavaScript, Rust or hand-written WebAssembly?
We took fifteen real jobs from Memento, wrote each one in every world where it made sense, and timed them in the engines behind Chrome and Safari, warm and cold. WebAssembly won where the work was numbers in a tight loop. Elsewhere, better JavaScript usually won first.
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.
One of Memento's colour themes, the Night Garden, draws an animated night sky behind the app. The top bar and the sidebar look like frosted glass over that sky, and small spores drift across it. While chasing a typing lag in that theme we moved the sky into a Web Worker, a background thread, so the main thread stays free for typing. The worker then had to make the frosted glass itself, by blurring the sky every frame, and the obvious question came up: should that loop be JavaScript, Rust compiled to WebAssembly, or WebAssembly written by hand?
So we answered it properly. Part one takes four jobs from the screens that felt slow and, where it made sense, writes each one three times. Part two adds seven more: encryption, password hashing, HTML cleaning and zip archives. Part three runs four texture generators, the code that computes the surfaces of a 3D scene, at every size from 64 to 4,096 pixels, warm and cold. We checked that every version produces the same output, and timed them in V8 (Chrome, Edge) and JavaScriptCore (Safari, through a WebKit build). We wanted SpiderMonkey too, but the Firefox test build would not start on this machine, so Firefox is missing.
The three worlds
To be sure we understood the bottom of the stack, we also went one step further than text: a WebAssembly module typed in as raw bytes, with no assembler at all. It exports one function, the fixed-point divide at the heart of the blur. It is 49 bytes long, and every browser loads it.
00 61 73 6d 01 00 00 00 header "\0asm" version 1
01 07 01 60 02 7f 7f 01 7f type: (i32, i32) -> i32
03 02 01 00 function 0 uses type 0
07 07 01 03 64 69 76 00 00 export "div"
0a 11 01 0f 00 20 00 20 01 6c 41 80 80 02 6a 41 10 76 0b code: (a * b + 32768) >> 16
The whole module. Integers are LEB128-encoded, which is why the constant 32768 takes three bytes: 80 80 02.
How we measured
Same contract, same answer. Every version of a job takes the same input and must produce the same output. The blur must match the TypeScript reference byte for byte; the spores to within 0.03 px; previews and search scores exactly. All did.
Outside the app. Each job runs on its own test page, so nothing else competes for the thread. The numeric jobs run inside a worker, as they would in the app.
Fine timers. Pages are served cross-origin isolated, a browser mode that gives each engine its most precise clock. Sub-millisecond jobs are timed in batches.
Medians, warm and cold. "Warm" means the code has already run a few times: we throw those warm-up runs away and report the median of the rest. "Cold" (part three) means the very first call in a fresh worker, which is what a job done once actually costs.
A busy machine, honestly. Other test suites were running on the same Mac (load average 6–10 during the Chrome run, 13–22 during WebKit), so absolute times are higher than in a quiet room, and the engines should not be compared with each other. Within one engine every world ran under the same conditions, so the ratios hold.
1 · The blur: frosted glass, every frame
The frosted glass on the top bar and sidebar is the sky behind them, blurred. The browser can do this itself with CSS (backdrop-filter: blur(18px)), but once the sky is drawn in a worker, the worker has to blur each frame on its own. If the blur is slow, the sky stutters.
The blur is a three-pass box blur over an RGBA image (four bytes per pixel: red, green, blue, alpha). A box blur replaces each pixel with the average of its neighbours within a radius, here 18 pixels; three passes in a row look close to a smooth blur. It is done with a running sum that slides along each row and then each column: add the pixel entering the window, subtract the one leaving. That is pure integer arithmetic over a large buffer, the textbook case for WebAssembly.
What we measured is the time to blur one frame of the sky, 720 × 450 pixels. The number that matters is the frame budget: at 60 frames per second each frame has 16.7 ms in total, and the blur has to fit inside it with room to draw the sky.
The chart has three versions:
- JavaScript: the TypeScript reference blur, written the normal way.
- Rust: the version we built for the app. It uses SIMD written out by hand and no standard library (
no_std), and the module is 1.3 KB. SIMD (single instruction, multiple data) means one instruction works on a 128-bit register holding several values at once, here the four channels of one pixel. - By hand: WebAssembly text written instruction by instruction, also with SIMD. The module is 837 bytes.
- JavaScript
- Rust → wasm
- Hand-written wasm
One sky frame. At 60 frames per second a frame has 16.7 ms in total, so the JavaScript blur alone would take more than two frames.
Both engines tell the same story. In Chrome the JavaScript blur took 40 ms and both WebAssembly versions about 3.9 ms, ten times faster. In WebKit JavaScript was a little quicker (32 ms), so the gap was 7–9×, with Rust at 4.5 ms and the hand-written module at 3.7 ms. For the user, that is the difference between a sky that skips frames and one that moves smoothly with time to spare.
The interesting part is which Rust. We wrote the same Rust blur in three ways, and set them against the hand-written module:
- Plain loop: ordinary Rust, compiled without SIMD.
- + simd128 flag: the same plain loop, with the compiler allowed to use SIMD wherever it can find a way (auto-vectorisation).
- Intrinsics: SIMD written out explicitly with intrinsics, Rust functions that each map to one WebAssembly SIMD instruction. This is the benchmark build, with the standard library.
- By hand: the hand-written module from the chart above.
- Rust → wasm
- Hand-written wasm
The speed comes from SIMD, not from the language. Rust intrinsics and hand-written wasm trade places by image size (see the full table), within about 20% of each other.
Written as a plain loop, Rust was only about twice as fast as JavaScript (17 ms against 40 in Chrome). Turning on the SIMD flag changed nothing: the running sum depends on the previous pixel, so LLVM, the compiler behind Rust, could not vectorise it. The big gain came from writing the SIMD by hand, with intrinsics that hold one pixel's four channels in one 128-bit register. Hand-written WebAssembly does the same thing and lands in the same place.
Rust · store one blurred pixel
let v = u32x4_shr(i32x4_add(i32x4_mul(sum, vinv), vround), 16);
let z = i32x4_splat(0);
v128_store32_lane::<0>(u8x16_narrow_i16x8(u16x8_narrow_i32x4(v, z), z), p);
Hand-written wasm · the same store
(v128.store32_lane 0 (local.get $dst)
(i8x16.narrow_i16x8_u
(i16x8.narrow_i32x4_u
(i32x4.shr_u
(i32x4.add (i32x4.mul (local.get $sum) (local.get $vinv)) (local.get $vround))
(i32.const 16))
(v128.const i32x4 0 0 0 0))))
Line for line the same instructions. Rust gives you types, bounds you can reason about and a test suite; the hand-written version gives you nothing extra at run time.
All image sizes and every variant
| Image | World | Chrome | WebKit |
|---|---|---|---|
| quarter-res screen 720x450 | TypeScript | 40.4 ms | 32.4 ms |
| quarter-res screen 720x450 | Rust, plain loop | 17.2 ms | 16.1 ms |
| quarter-res screen 720x450 | Rust, auto-vectorised | 16.9 ms | 16.1 ms |
| quarter-res screen 720x450 | Rust, SIMD intrinsics | 4.63 ms | 5.28 ms |
| quarter-res screen 720x450 | Rust, no_std app kernel | 3.92 ms | 4.52 ms |
| quarter-res screen 720x450 | Hand-written wasm | 3.85 ms | 3.68 ms |
| sidebar @2x 690x1624 | TypeScript | 131 ms | 118 ms |
| sidebar @2x 690x1624 | Rust, plain loop | 60.8 ms | 58.7 ms |
| sidebar @2x 690x1624 | Rust, auto-vectorised | 71.4 ms | 71.9 ms |
| sidebar @2x 690x1624 | Rust, SIMD intrinsics | 17.2 ms | 39.9 ms |
| sidebar @2x 690x1624 | Rust, no_std app kernel | 16.2 ms | 18.7 ms |
| sidebar @2x 690x1624 | Hand-written wasm | 15.8 ms | 16.8 ms |
| full screen @2x 2880x1800 | TypeScript | 705 ms | 573 ms |
| full screen @2x 2880x1800 | Rust, plain loop | 454 ms | 381 ms |
| full screen @2x 2880x1800 | Rust, auto-vectorised | 414 ms | 379 ms |
| full screen @2x 2880x1800 | Rust, SIMD intrinsics | 75.6 ms | 88.6 ms |
| full screen @2x 2880x1800 | Rust, no_std app kernel | 151 ms | 90.4 ms |
| full screen @2x 2880x1800 | Hand-written wasm | 93.9 ms | 103 ms |
2 · The spores: maths in a loop
The spores are the small specks that drift across the Night Garden sky. Each one moves on a sine wave: its position is its starting point plus the sine of the time and its own phase, scaled. Computing every spore's new position is one animation step. In the app this maths already runs on the graphics card, in a shader; here we ran the same maths on the processor, as a clean example of a numeric loop.
What we measured is one step for 10,000 spores, far more than the app draws, so that the time is large enough to measure reliably. The number that matters is again the share of a 16.7 ms frame.
The JavaScript as first written used Math.sin. A fast polynomial sine, an approximation that stays within 0.03 px of the real one, was about twice as fast in JavaScript alone (2.4× in Chrome, 1.8× in WebKit), before any WebAssembly. We gave every world that same fast sine, so the chart compares languages, not algorithms:
- JavaScript: the spore step with the fast sine.
- Rust: the same loop compiled to WebAssembly with SIMD. The plain loop with the SIMD flag and the version with intrinsics took the same time.
- By hand: hand-written WebAssembly with SIMD, 475 bytes.
- JavaScript
- Rust → wasm
- Hand-written wasm
One animation step for 10,000 spores. The app uses about 140, where every version takes well under a microsecond.
Chrome: 52 µs in JavaScript, 9.4 µs in Rust, 10.8 µs by hand. WebKit: 56 µs, 8.4 µs and 8.3 µs. WebAssembly is 5–7 times faster, and Rust and the hand-written module are level.
This loop has no dependency between spores, so here LLVM did vectorise it on its own: the plain Rust loop with the SIMD flag ran as fast as the version with intrinsics. That is the same compiler and flag that failed on the blur. Whether auto-vectorisation helps depends on the shape of the loop, and you only find out by measuring.
What it means for the user: nothing to change. The app's 140 spores move on the graphics card, and even on the processor, in JavaScript, they would take about a microsecond per frame out of 16,700. The ratio is real, the time is not.
3 · Previews and search: strings, not numbers
The other two jobs are text, and we did not write them by hand in WebAssembly: parsing markdown in raw instructions is not a serious option. The comparison here is JavaScript against Rust.
Previews. The notes list shows a card for each note with a short plain-text preview. Many notes are stored as markdown, so to make the preview the app renders the markdown to HTML and reads the text back out. We measured that for a list of 500 notes, the work behind showing the list:
- JavaScript: what the app does today:
markedturns the markdown into an HTML string, the browser'sDOMParserparses it, and the text is read back out. - Rust:
pulldown-cmark, a Rust markdown parser, collects the text directly, with no HTML in between.
- JavaScript
- Rust → wasm
Rust is clearly faster on previews: it never builds an HTML string or a DOM.
For 500 markdown notes Chrome took 35 ms in JavaScript and 6.5 ms in Rust; WebKit 60 ms and 7.3 ms. Notes saved by the editor as HTML are cheaper for JavaScript (14 and 22 ms) and cost Rust the same 6–7 ms. Every one of the 1,000 previews matched exactly.
Search. Search runs on the device, over the decrypted notes. It gives every note a score for how well it matches the query, with points for matches in the title, the content and the tags, and shows the best first. We measured six searches over 930 notes and added up the time:
- As shipped: the app's current scoring code.
- Tidied: the same scoring, with repeated work done once instead of for every note (explained below).
- Rust: the scoring ported to Rust and compiled to WebAssembly.
- JavaScript
- Rust → wasm
“Tidied” JavaScript does the same work once instead of per item: lower-case each note when the list loads, build one regular expression per search term.
Across the six searches, Chrome took 8.3 ms with today's code, 4.2 ms tidied and 5.9 ms in Rust. WebKit: 11.5, 4.3 and 5.9 ms. All three gave identical scores for every note and query.
Search is where Rust stopped winning. Most of the time went to repeated work, not to the language: today's code lower-cases every note and builds a new regular expression for every note on every search. Hoisting that out made JavaScript faster than the Rust port, which also has to copy every query across the boundary between JavaScript and the module. JavaScript engines are very good at short string operations.
Part two
Seven more jobs: encryption, cleaning and archives
Four jobs is a narrow slice of an app, and it left out the work Memento does most: encryption. So we went back for the jobs that run on every unlock, every load and every import, and gave each the same treatment. This part adds a fourth world, because Memento already runs its encryption in WebAssembly: libsodium is C, compiled to wasm with Emscripten.
In the charts below, libsodium is that C library, exactly as the app ships it. noble is the JavaScript world: the audited, pure-JavaScript @noble/ciphers and @noble/hashes. Rust is the RustCrypto crates compiled to WebAssembly, and Rust SIMD is the same Rust built with the SIMD flag.
Correctness first, again. For the same key and nonce (a number used only once per encryption), every world had to produce the same bytes as libsodium, open what libsodium sealed, refuse a ciphertext with one flipped bit, and reproduce the official ChaCha20 test vector from RFC 8439. All of them did, including the hand-written SIMD version on its first run. This is a benchmark, not a proposal to change Memento's cryptography: the app's formats and libsodium stay as they are.
4 · Opening 500 notes
Every note is stored encrypted, so before the notes list can show a title, the device has to open the note. Each note arrives as an envelope: a small JSON record holding the note's own key, itself encrypted with the device's key, and the encrypted title and content, all as base64 text. Opening it means parsing the envelope, unwrapping the note's key (XSalsa20-Poly1305), then decrypting the title and the content (XChaCha20-Poly1305). The "-Poly1305" part is an authentication tag: it proves the data was not changed, and decryption fails if it was.
What we measured is the total time to open 500 notes. The number that matters is how long the list waits before it can show them:
- libsodium: today's app, five calls into the C module per note through its JavaScript wrapper.
- noble: the same steps in pure JavaScript.
- Rust: one call per note that does the base64, the key unwrap and both decryptions inside the module.
- Rust SIMD: the same Rust, built with the SIMD flag.
- JavaScript
- Rust → wasm
- C → wasm
500 seeded notes, 1.1 MB of envelopes. Every world returned exactly the original title and content for all 500.
libsodium took 13.1 ms in Chrome and 15.0 ms in WebKit, pure JavaScript 15.7 and 21.2 ms, and Rust 5.7–8.6 ms.
Rust opened the notes about twice as fast as libsodium, which surprised us, since both are compiled code running the same ciphers. The difference is most likely the trips across the boundary: through the libsodium wrapper every note costs five calls, each copying its input into the C heap and its output back out, while the Rust function takes one note and does everything in a single call. On big buffers (next) the two are level. Either way, 13 ms for 500 notes is not what makes the app feel slow.
5 · Encrypting a file
When you attach a file, it is encrypted on the device before it is uploaded, in one XChaCha20-Poly1305 call. Its file name and type are bound to it as additional data: they are not encrypted by this call, but the tag covers them, so a file cannot be quietly given another file's name.
What we measured is sealing one 16 MiB file. The number that matters is how long you wait between choosing the file and the upload starting:
- libsodium, noble, Rust, Rust SIMD: as above.
- AES-GCM (grey): the browser's own AES-256-GCM through WebCrypto. It is a different cipher, shown only for scale.
- JavaScript
- Rust → wasm
- C → wasm
- Native (browser)
One 16 MiB file, sealed. The grey bar is the browser's own AES-256-GCM, shown for scale: it is a different algorithm and would mean a new encrypted format.
libsodium took 43 ms in Chrome and 52 ms in WebKit; Rust with SIMD 43 and 28 ms; pure JavaScript 107 and 78 ms. So libsodium and Rust with SIMD are level, and pure JavaScript is about 1.5–2.5× slower. For a 16 MiB file, all of them finish in a fraction of a second.
The browser's built-in AES-GCM (11 and 6 ms) is 4–5× faster than any of them, because Apple's chip has AES instructions and no WebAssembly world can reach those. That is the one place the fastest option is neither of our languages, and it is not available without changing the format.
6 · The lock check: Argon2id
When you unlock Memento with a PIN or a password on the device, the app checks it against an Argon2id hash. Argon2id is a password-hashing function built to be slow and to need a lot of memory. The benchmark here runs it at 3 passes over 256 MiB, the lab's test setting, not the setting Memento uses. That cost is the point, since it makes guessing slow. The only question is how long a legitimate unlock waits.
What we measured is one check, from PIN to answer:
- libsodium: the library the app uses.
- Rust: the RustCrypto
argon2crate. - Rust SIMD: the same crate built with the SIMD flag.
- noble:
@noble/hashes, in pure JavaScript.
- JavaScript
- Rust → wasm
- C → wasm
Argon2id benchmark: one check, 3 passes, 256 MiB, the lab's test setting. All worlds produced the identical 32-byte hash.
libsodium and Rust are level at 0.4–0.6 s; the SIMD flag did not help. Pure JavaScript took 3.4–10.7 s for the same hash: up to 11 seconds in front of a lock screen. That shows why password hashing is the textbook job for WebAssembly: 64-bit arithmetic over a large memory block is exactly what JavaScript numbers are bad at.
7 · Inside the cipher: ChaCha20 by hand
This one is not a feature but the engine inside the last three jobs. XChaCha20-Poly1305 encrypts with ChaCha20, which turns the key and nonce into a stream of random-looking bytes (the keystream) and XORs it with the data. It makes the keystream in 64-byte blocks, and each block is independent of the others, so in principle several can be computed at once. The basic mixing step is called a quarter round.
To see the ceiling, we took ChaCha20 alone and timed the encryption of 1 MiB:
- noble, libsodium: as above.
- Rust: the RustCrypto
chacha20crate. Rust SIMD: the same, with the SIMD flag. - By hand: hand-written WebAssembly with plain 32-bit operations, one block at a time.
- By hand SIMD: hand-written, four blocks side by side in SIMD registers, one block per lane. A short script repeats the quarter round the way an assembler macro would; every instruction in it was chosen by hand.
- JavaScript
- Rust → wasm
- Hand-written wasm
- C → wasm
1 MiB XORed with the keystream. Every version produced the same bytes as libsodium.
The hand-written SIMD version took 1.29 ms in Chrome and 0.68 ms in WebKit, against 2.17 and 1.24 ms for libsodium. This is the one job where hand-written WebAssembly won outright: 1.7–1.8× faster than libsodium and Rust. For the user nothing changes, since the app keeps libsodium; it is a finding about the libraries.
The reason is not skill, it is coverage. Neither library has a WebAssembly SIMD path for ChaCha20 (they have them for x86 and ARM), so both fall back to one block at a time, and the compiler's auto-vectoriser does not discover the four-lane layout on its own. The hand-written module is 3.9 KB.
Hand-written wasm · one step of a quarter round, four blocks at once
(local.set $v0 (i32x4.add (local.get $v0) (local.get $v4)))
(local.set $v12 (v128.xor (local.get $v12) (local.get $v0)))
;; rotate left 16 = swap the halves of every lane: one byte shuffle
(local.set $v12 (i8x16.shuffle 2 3 0 1 6 7 4 5 10 11 8 9 14 15 12 13
(local.get $v12) (local.get $v12)))
8 · Cleaning HTML
When you import or paste HTML, it can carry code: a script tag, an event handler, a link that runs JavaScript. Before it is shown, it goes through a sanitiser, which keeps only an allow-list of safe elements and attributes and drops the rest. The app uses DOMPurify with a strict allow-list.
What we measured is cleaning 500 notes' worth of HTML:
- DOMPurify: today's app. It uses the browser's own HTML parser.
- ammonia: the Rust sanitiser, built on html5ever, the HTML parser from Mozilla's Servo project. We configured it to the same intent: no scripts, frames, forms or inline styles, and only web, mail and phone links.
- JavaScript
- Rust → wasm
500 notes rendered from markdown, 777 KB of HTML. Both kept the same visible text and the same elements in every note.
DOMPurify took 20 ms in Chrome and 26 ms in WebKit; ammonia 6 ms in both, 3.4–4.4× faster. Both stopped all 30 known attack shapes we threw at them (our detector flags 26 of those 30 in the raw input, so a clean result means something).
The speed comes at a size: the ammonia module is 1.3 MB, 469 KB compressed, against DOMPurify's 23 KB, because it carries a full HTML5 parser while DOMPurify borrows the browser's. For a security boundary, using the browser's own parser is also the safer design: it sees the HTML exactly as the page will.
9 · Archives: export and import
Exporting your notes downloads one zip file with every note as markdown plus a JSON copy. Importing from Google Keep goes the other way: unzip Keep's archive, which has one JSON file per note. Here the third world is a second JavaScript library, fflate, because the first lesson of part one applies again.
What we measured is building an export of 501 files, and reading a Keep archive of 500 notes:
- JSZip: the library the app uses today.
- fflate: another JavaScript zip library.
- zip: the Rust
zipcrate, compiled to WebAssembly. - Stored means the files are packed without compression, which is what the app does today. Deflate means they are compressed.
- JavaScript
- Rust → wasm
501 files, 1.5 MB. “Stored” is what the app does today, so its exports are not compressed: 1.6 MB instead of about 565 KB.
- JavaScript
- Rust → wasm
A Keep-style archive of 500 deflated JSON notes (395 KB). Every world returned identical files.
Building today's uncompressed export took JSZip 22–23 ms, fflate 3.6–5.1 ms and Rust 1.3–2.1 ms. Reading the Keep archive took JSZip 23–33 ms, fflate 6.4–15 ms and Rust about 3 ms.
Rust is the fastest, but the bigger gap is between the two JavaScript libraries: fflate is 2–7× faster than JSZip at everything, and the app already has it installed through another package. Switching library wins most of the time without any WebAssembly, and turning on compression would make exports a third of the size for about 30 ms.
Part three
10 · Procedural stone, at every size
The last jobs are the most purely numeric code in the repository: the texture generators for 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 the device from a formula. Each surface needs four images, or maps, that the 3D renderer combines: the colour, a normal map for the small bumps that catch the light, how rough or shiny each point is, and soft shadow in the cracks (ambient occlusion).
The stone is made by makeStone. Its formula is value noise: random-looking but smooth patterns, made by hashing the coordinates of a grid and blending between grid points, with several layers at different sizes on top of each other. A Sobel filter, which compares each pixel with its neighbours to find the slope, and Math.hypot then turn the result into the bump map. That is about twelve noise lookups and forty-eight integer hashes per pixel. The scene is not on the live site yet, but the code and its tests are, so it makes an honest benchmark.
The JavaScript world is the app's own function, checked byte for byte against the module in the repository. Every other world had to match it exactly at every size, from 64 to 4,096 pixels square. All of them did, in both engines.
What we measured first is building all four maps at 512 × 512, the size the scene uses, warm:
- JavaScript: the app's
makeStone. - Rust, literal port: a line-for-line translation to Rust.
- Rust, one-line fix: the same port with one line changed, explained below.
- Hand-written: hand-written WebAssembly, one pixel at a time.
- Hand-written SIMD: hand-written, two pixels per step, eight hashes in two 4-lane operations.
- JavaScript
- Rust → wasm
- Hand-written wasm
512 × 512, the size the scene uses. Median of 9 runs in an already-warm worker.
JavaScript took 38 ms in Chrome and 32 ms in WebKit. The literal Rust port took 78 and 71 ms, twice as slow. With the fix, Rust took 22 and 17 ms, and the hand-written SIMD version 23 ms in both.
Reading the compiled module explained the slow port: WebAssembly has no floating-point remainder instruction, so Rust's % on an f64 became calls to a software fmod routine, from forty places in the hot loop. V8 sees that the numbers are small integers and takes a fast integer path. One line fixed it.
Rust · the literal port, and the one-line fix
// f64 % has no wasm instruction: this compiles to a call to a software fmod
let x0 = (((xi % period) + period) % period) as i32;
// The lattice coordinate is a whole number, so the integer remainder is exact
let x0 = (xi as i32).rem_euclid(period as i32);
Same output, byte for byte. At 512 px: 78 ms became 22 ms in Chrome, 71 ms became 17 ms in WebKit.
With that fixed, Rust and the hand-written SIMD version are level, about twice as fast as JavaScript. Rust gets there without SIMD: once everything is inlined, the compiler knows each octave's period (the grid size of each noise layer) as a constant, replaces the remainder with cheaper arithmetic and unrolls the loop. Giving the hand-written version an integer remainder made it slower, because with the period only known at run time that remainder is a real division.
Cold start: where the gap opens
A scene builds each texture once, so the number that matters most is the first call in a fresh worker, with compiling and starting the module included. That is the time before the stone can appear. The chart shows the same build at 512 × 512 for the three versions worth comparing: JavaScript, Rust with the one-line fix and hand-written SIMD.
- JavaScript
- Rust → wasm
- Hand-written wasm
512 × 512, a fresh worker every time, median of 5. Compiling and starting a module costs 0.1–0.4 ms of that.
In WebKit, the JavaScript version's first call takes 157 ms against 25 ms for Rust: six times slower, nearly ten frames against under two. Safari's engine has not finished optimising the function by the time a one-shot call is over. In Chrome the first call costs 48 ms in JavaScript against 26–30 ms in WebAssembly.
Chrome shows the opposite wrinkle at large sizes. V8 first runs WebAssembly through a quick baseline compiler and swaps in optimised code later, but a call that is already running keeps the baseline code. So a 2,048-pixel texture done in one long call took 477 ms cold against 303 ms warm. A 2 ms priming call brought it down to 321 ms; a tiny one did nothing. The practical rule is to process big jobs in chunks, which gives the engine a chance to upgrade the code between them.
The cost per pixel stays flat from 64 to 4,096 pixels, so these ratios hold at every size:
Every size, both engines: nanoseconds per pixel, warm
| World | Engine | 64 px | 128 px | 256 px | 512 px | 1024 px | 2048 px | 4096 px |
|---|---|---|---|---|---|---|---|---|
| JavaScript | Chrome | 153 | 148 | 144 | 144 | 141 | 141 | 142 |
| JavaScript | WebKit | 381 | 129 | 122 | 124 | 120 | 117 | 114 |
| Rust, literal port | Chrome | 303 | 300 | 295 | 296 | 288 | 288 | 288 |
| Rust, literal port | WebKit | 259 | 253 | 275 | 272 | 256 | 294 | 261 |
| Rust, one-line fix | Chrome | 73 | 72 | 73 | 83 | 74 | 72 | 72 |
| Rust, one-line fix | WebKit | 59 | 60 | 68 | 63 | 71 | 75 | 58 |
| Hand-written | Chrome | 110 | 111 | 109 | 127 | 109 | 109 | 109 |
| Hand-written | WebKit | 93 | 92 | 93 | 118 | 106 | 118 | 91 |
| Hand-written SIMD | Chrome | 72 | 72 | 73 | 89 | 72 | 72 | 72 |
| Hand-written SIMD | WebKit | 64 | 62 | 63 | 88 | 72 | 63 | 61 |
11 · Marble and the sky map: who should compute the sine?
The same file has two more generators. Marble makes the scene's marble surfaces: noise fields bent into veins with sines, each vein sharpened with a power function. The sky map is an environment map: a panoramic image of the surroundings that the 3D renderer uses for light and reflections. Each of its pixels needs three cosines and little else.
They ask a question the stone never did. WebAssembly has no instruction for sine, cosine or power, so the module either carries its own maths library (Rust's, compiled in) or calls the browser's Math functions across the boundary. We built both, for both kernels, and measured nanoseconds per pixel, warm, so the two can be compared:
- JavaScript: the app's function.
- Rust, own maths: Rust's own software
sin,cosandpow, compiled into the module. - Rust, JS Math: the same Rust, calling the browser's
Math.sin,Math.cosorMath.powacross the boundary for each value. - Rust, SIMD flag (sky map only): Rust's own maths, built with the SIMD flag.
- Hand-written (SIMD), JS Math: hand-written WebAssembly calling the browser's
Math. For marble the noise runs two pixels at a time with SIMD.
Every version matched JavaScript to the last byte (marble) and the last float bit (sky map), in both engines.
- JavaScript
- Rust → wasm
- Hand-written wasm
Marble: two sines and three powers per pixel. Nanoseconds per pixel at 1024 × 1024, warm.
- JavaScript
- Rust → wasm
- Hand-written wasm
Sky map: three cosines per pixel and little else. Nanoseconds per pixel at 1024 × 512, warm.
For marble, Rust with its own maths was barely faster than JavaScript (198 against 210 ns per pixel in Chrome), and calling the browser's Math brought it to 148 ns. For the sky map it was the other way round: Rust with its own cosine took 15 ns against 37 ns for JavaScript in Chrome, and calling Math.cos brought it back to 34.
The two results point in opposite directions, and that is the lesson. pow is expensive in software, so for marble the browser's native Math.pow was worth the trip across the boundary: importing it made Rust 1.4× faster than Rust's own. For the sky map cos is cheap, so the trips cost more than they saved: Rust with its own cosine is 2.4× faster than JavaScript in Chrome, and the version that imports Math.cos is no faster than JavaScript at all. Import the expensive functions, keep the cheap ones inside the module, and measure which is which.
12 · The vault face: is the canvas the slow part?
The last generator makes the vault door's face, its dial. It draws the face with the browser's 2D canvas (concentric bands, Roman numerals with fillText, laurel strokes), reads the pixels back, and then turns the drawing into the same four texture maps, with two more noise layers on the way. It was tempting to suspect the text drawing.
What we measured is the whole job at 2048 × 1024, the size the scene uses, warm:
- JavaScript: the app's function, for everything after the drawing.
- Rust: the same pixel work in Rust.
- By hand SIMD: hand-written WebAssembly, two pixels per step through both noise layers.
- Canvas draw (grey): drawing the face and reading it back. This is the browser's native code, the same in every world, so it was done once and every world got the same pixels.
- JavaScript
- Rust → wasm
- Hand-written wasm
- Native (browser)
2048 × 1024, the size the scene uses, warm. The grey bar is the canvas drawing and read-back, the same native code for every world. All outputs byte-identical.
The canvas drawing took 5.3 ms in Chrome and 2.7 ms in WebKit: 3% of the job. The pixel work is the rest: 189 and 139 ms in JavaScript, 99 and 79 ms in Rust. So moving the drawing would not help; moving the pixel loops would.
Rust is 1.8–1.9× faster than JavaScript in both engines, and on the first call in WebKit 2.5×. The hand-written SIMD version is second again, for the same reason as the stone: the compiler sees the noise periods as constants, the hand-written helpers do not. One footnote on method: the first run of this kernel happened while another job was hammering the machine, and its numbers were plainly wrong (JavaScript three times slower in Chrome than in WebKit). We threw it away and re-ran once the load had stayed low.
The price of a module
Speed is half the bill. The other half is what the user downloads, how long the browser takes to compile it, and, for an encrypted app, what you copy where. The part one modules:
| Module | Bytes | gzip | Compile, Chrome | Compile, WebKit |
|---|---|---|---|---|
| blur, hand-written WAT | 837 | 380 | 0.06 ms | 0.08 ms |
| blur, Rust (no_std app kernel) | 1,284 | 747 | 0.03 ms | 0.12 ms |
| blur, Rust (std, bench build) | 28,323 | 10,658 | 0.1 ms | 0.2 ms |
| spores, hand-written WAT | 475 | 319 | 0.03 ms | 0.1 ms |
| search, Rust | 46,216 | 19,770 | 0.12 ms | 0.24 ms |
| preview, Rust + pulldown-cmark | 259,049 | 95,904 | 0.33 ms | 0.68 ms |
Compile time is for the module bytes already in memory, median of 11. For comparison, marked is 12.8 KB gzipped and the TypeScript blur 0.5 KB.
Compiling is free. Even the 253 KB markdown module compiles in under a millisecond (0.3 ms in Chrome, 0.7 ms in WebKit). WebAssembly start-up was never the problem.
Size is a choice. The same Rust blur is 28 KB with the standard library and 1.3 KB without it (
no_std, a tiny allocator,panic = "abort"). The hand-written one is 837 bytes. Rust can get within a few hundred bytes of hand-written code if you ask it to.Libraries are not. The markdown parser brings 94 KB gzipped against marked's 12.8 KB, and the HTML sanitiser 469 KB against DOMPurify's 23 KB. The part two crypto module is 24 KB gzipped; the hand-written ChaCha20 1.3 KB.
Copies matter in an encrypted app. A WebAssembly module has its own block of memory, and data has to be copied into it. Search in Rust means copying every decrypted note there: a second copy of plaintext that the lock screen also has to clear. That cost does not show up in a benchmark.
The verdict, job by job
| Job | Best world | Why |
|---|---|---|
| Glass blur, every frame | Rust, no_std, 1.3 KB | 7–10× faster, the difference between a smooth and a stuttering sky. Level with hand-written, and maintainable. |
| Spores | GPU shader (keep) | Wasm is 5–7× faster on the processor, but the app's 140 spores already move on the graphics card, and would take about 1 µs even in JavaScript. |
| Note previews | JavaScript | Rust 5–8× faster on a job that costs milliseconds once; 82 KB more to download. |
| Search ranking | Tidied JavaScript | Beats the Rust port and keeps decrypted text in one place. |
| Opening notes | libsodium (keep) | Rust is 2× faster, but 500 notes take 13–15 ms either way. Not worth a second crypto stack. |
| Encrypting files | libsodium (keep) | Level with Rust; JavaScript 1.5–2.5× slower. Native AES-GCM is faster still, but a different format. |
| Lock check, Argon2id | libsodium (keep) | Level with Rust at 0.4–0.6 s; JavaScript takes 3–11 s. This one must be wasm. |
| ChaCha20 core | Hand-written SIMD | 1.7–1.8× faster than any library, because none has a wasm SIMD path. A finding, not a change: the app keeps libsodium. |
| Cleaning HTML | DOMPurify (keep) | ammonia is 3–4× faster but 20× bigger, and the browser's own parser is the safer boundary. |
| Export and import zips | fflate | 2–7× faster than JSZip, already installed. Rust is faster again, but the JavaScript swap gets most of it. |
| Procedural textures | Rust, one-line fix | 2× faster warm, up to 6× on the first call in WebKit; level with hand-written SIMD. Only matters once the 3D scene ships. |
| Vault face | Rust | 1.8–1.9× warm, 2.5× cold in WebKit. The canvas drawing is 3% of the job; the pixel loops are the rest. |
| Marble and sky map | Rust, with the right maths | Import Math.pow (1.4× better than Rust's own), keep cos inside (2.4× JavaScript). Only once the 3D scene ships. |
WebAssembly wins where the work is numbers in a tight loop: a blur every frame, a password-hash benchmark at 256 MiB. Where the work is text, or runs once, the JavaScript engine is already fast enough, and a better JavaScript library usually beats a new language.
Rules of thumb
- Measure the job before picking the world. Two of our four jobs were won by fixing the JavaScript: a cheaper sine, and work hoisted out of a loop.
- SIMD is the speed-up, not WebAssembly. Scalar Rust was about 2× faster than JavaScript on the blur; SIMD made it 7–10×. Check whether the compiler vectorised your loop instead of assuming it.
- Write Rust, not wasm text, unless nobody wrote the SIMD. Against Rust intrinsics, hand-written WebAssembly never won by more than about 20%. It won clearly only on ChaCha20, where no library has a wasm SIMD path. That is a gap to report upstream, not a reason to hand-roll cryptography in an app.
- Try the other JavaScript library first. fflate against JSZip, a cheaper sine, hoisted regular expressions: three of eleven jobs got most of their speed-up without leaving JavaScript.
- Let the platform do what it has hardware for. The browser's AES-GCM beat every WebAssembly cipher by 4–5×, and its HTML parser is what DOMPurify relies on. Neither can be matched from wasm.
- Read the wasm, not just the source. One
%on a float made a Rust port twice as slow as JavaScript, and only the compiled module showed why.wasm2waton every build is cheap. - Time the first call for work done once. Warm medians flatter JavaScript: a one-shot texture took 157 ms in WebKit's JavaScript against 25 ms in Rust. In Chrome, split long wasm jobs into chunks so the engine can optimise between them.
- Import the expensive maths, keep the cheap maths. wasm has no sine or power instruction. The browser's native
Math.powwas worth a boundary crossing;Math.coswas not. - Count the bytes and the copies. A
no_stdkernel is a rounding error; a library port can be a third of a bundle. In an end-to-end encrypted app, every extra copy of plaintext is also something to clear on lock.
What comes next
This is the first Memento Lab experiment. Everything above was measured in a lab, on copies of the app's code, not in the version you use today. The next post comes after the winning changes ship in Memento, with before-and-after numbers from the real app.
Versions and method
Part two: libsodium-wrappers 0.8.4 (and the sumo build for Argon2 and the bare ChaCha20), @noble/ciphers 1.3.0, @noble/hashes 1.8.0, chacha20poly1305 0.10.1, crypto_secretbox 0.1.1, argon2 0.5.3, chacha20 0.9.1, DOMPurify 3.4.16, ammonia 4.2.4, JSZip 3.10.1, fflate 0.8.3, zip 9.0.5. Machine load average 4–7 during part two.
Part three: the app's makeStone checked byte for byte against textures.ts; Rust without SIMD intrinsics; hand-written module 5.1 KB with scalar, SIMD and integer-remainder variants. Sizes 64 to 4,096 px; warm medians in one worker, cold starts in a fresh worker per run (median of 5). Load average 3.4–4.9.
Method: Apple M4, Chrome for Testing (V8) and Playwright's WebKit build (JavaScriptCore), headless, cross-origin isolated. Blur: 3 warm-up and 15 timed runs per size. Spores: per-call time over many calls. Previews: 500 seeded notes in markdown and in editor HTML. Search: 930 seeded items, six queries, batches of 20. Every result was checked against the JavaScript reference before timing.
Earlier in-app measurements on a quiet machine gave the same picture at lower absolute times: in the app's worker the Rust blur took 2.2 ms per frame against 21 ms for TypeScript.