Correction, 7 August 2026. The version of this post published on 28 March 2026 claimed I had built a 6.3 KB Rust + WASM fraud-detection engine that scored 50,000 transactions per second in the browser, 10× faster than JavaScript, and that it ran in production for QuackNet. I am withdrawing those claims. I cannot produce the module, the benchmark harness, or the measurement that produced 10×, and the lab page that was supposed to demonstrate it — /lab/rust-wasm-fraud/ — contained no WebAssembly at all: its runWASM() function was JavaScript, and line 3 of its source said Simulates JS-vs-WASM. Nobody complained. I found it while rebuilding the homepage around a claim-checking demo and could not leave it standing. The original text is below the line, unedited, so you can see exactly what was wrong with it.

What was actually true

The idea was true and I still hold it: when scoring is pure arithmetic over a fixed feature vector — no secrets, no state, no I/O — running it in the browser beats a network round trip, and a compiled module beats an interpreted one. That is a defensible engineering position. Everything I attached to it in this post was not defensible: the 6.3 KB, the 50,000 per second, the 64 baked-in isolation-forest trees, the LZ4-compressed tree arrays, the 20 µs versus 200 µs per call, the 10×. Those are the shapes numbers have when you write them from expectation rather than from a measurement. Several of them are also internally inconsistent — a post that reports both "50 transactions per second" and "50,000 fraud scores per second" for the same engine was not checked by anyone, including me.

What I can substantiate today: nothing in any repository I control contains a compiled .wasm fraud module, and there is no Rust toolchain on the machine that builds this site. So rather than quietly delete the post, I did the smallest honest version of the work it described.

218 bytes, written by hand, no toolchain

WebAssembly's binary format is documented and small. If the only thing standing between a claim and a fact is a compiler I do not have installed, I can emit the bytes myself. tools/gen-wasm.mjs in this site's repository writes a real module directly: LEB128 integer encoding, five sections — type, function, memory, export, code — one function body, 19 distinct opcodes, zero imports. It is 218 bytes. The generator refuses to write a file that WebAssembly.validate() rejects, then instantiates it and compares its output against the JavaScript reference over 200,000 seeded records. If the two flag counts differ it exits non-zero and the site does not deploy.

The function is the same seven-rule scoring pass the old lab page used, with every else if folded into two monotone predicates so the loop is branchless:

// velocity > 8 ? 30 : velocity > 4 ? 15 : 0 // == (velocity > 8) * 15 + (velocity > 4) * 15 // amount > 2000 ? 25 : amount > 800 ? 12 : 0 // == (amount > 2000) * 13 + (amount > 800) * 12 // deviceAge < 5 ? 25 : deviceAge < 15 ? 10 : 0 // == (deviceAge < 5) * 15 + (deviceAge < 15) * 10

Records are 12 bytes with no padding waste: f32 amount, u8 velocity, u8 hour, u16 device age, then three u8 flags. Both the JavaScript and the WebAssembly read the same buffer — the wasm module's exported memory — so the data layout is not an advantage of one of them.

The honest number is 2×, and three quarters of the win is not WebAssembly

Three implementations, 200,000 records, median of 25 repetitions, all three warmed first, and none of them allowed to report a time unless all three return the identical flag count. On an M-series laptop in V8:

78% of the time I eliminated, I eliminated before WebAssembly entered the picture. It came from stopping the allocation of one object per record and from replacing data-dependent branches with arithmetic. That is the correction with the most engineering in it: the old "WASM" column measured this effect and credited it to Rust. It is still the column worth having — it is a rewrite you can do in an afternoon in the language you already ship, with no new toolchain and no new deployment artefact.

And 2× is a fair number for this workload, not a disappointing one. Integer and f32 comparisons with no allocation and no calls is close to the best case for a JIT; that is exactly why the margin is 2× and not 10×. Dense f64 maths, manual memory management, or SIMD show more. A design that crosses the JS↔wasm boundary once per record shows less than 1× — which was the one genuinely useful gotcha in the original post, and I am keeping it.

Run all three on your own CPU → The page prints your numbers, not mine, and prints them only when the three implementations agree.

What I changed about how I write

Two rules, in force from today. A number in a post has to come from a command I can re-run. If I cannot re-run it, it does not get a digit — it gets a word like "faster", or it gets cut. A demo page may not label a code path with a technology it does not use, even in a comment, even as a placeholder, even when the surrounding maths is real. The word "simulates" in line 3 of that benchmark was me telling the truth to a reader who was never going to open the file, which is not telling the truth.

The homepage now boots a real five-node Raft cluster in five Web Workers and hands you a button that calls worker.terminate() on the elected leader. Every number it prints is measured on your machine between two performance.mark() calls you can list yourself. I could not ship that page while this one was three clicks away.


Everything below is the original post as published on 28 March 2026, kept unedited for the record. Its numbers are withdrawn. Do not cite them.

When I started building QuackNet's payment processing layer, I faced a hard choice: implement real-time fraud detection on the backend or push some calculation to the browser. The backend option was safer but meant latency on every transaction. The browser option meant shipping logic to users, but instant feedback and offline resilience. I picked Rust + WASM.

Most fraud systems are backend-only for good reason. But if you're doing geometric scoring—haversine distances, isolation forests, velocity checks—those calculations are pure math. No secrets, no state. The math works the same in the browser as on a server. I could compile it once and run it everywhere.

Why Rust + WASM Over JavaScript

I could have written the fraud engine in JavaScript. It would have worked. But the math is dense: computing isolation forest anomaly scores for 50 transactions per second, calculating great-circle distances between coordinates, building cumulative histograms for velocity checks. JavaScript would burn CPU.

I benchmarked early. A naive JS implementation of the isolation forest scorer hit 5,000 calculations per second on my laptop. The same logic in Rust, compiled to WASM, hit 50,000 per second. That's a 10x difference. For a fraud engine that needs to score every user action, that gap matters.

Rust also gave me predictability. No garbage collection pauses at critical moments. Strict type checking caught entire categories of bugs before compilation. And memory layout—I could pack fraud features into dense structures and read them in one cache line.

No Standard Library: The Custom Math Path

WASM modules run in a sandbox. You don't get libc. No std library. You get raw memory and the WebAssembly instruction set. For most applications, this means using a WASM runtime like Wasmer or Wasmtime. For a fraud engine that ships to browsers, it means writing custom implementations of the functions you need.

I needed sin, cos, and sqrt for haversine distance calculation. Standard library would have been ~500KB of compiled code. I wrote them myself using Taylor series approximations. Sqrt was Babylonian method (Newton-Raphson). Sin and cos used precomputed tables for the first 32 terms of the series, then early exit. Total compiled code for all three: about 200 bytes.

fn // Babylonian method for square root fn sqrt(x: f64) -> f64 { if x <= 0.0 { return 0.0; } let mut guess = x; for _ in 0..10 { guess = (guess + x / guess) / 2.0; } guess }

Haversine Distance: The Core Geometry

Fraud patterns hide in movement. If a user made a purchase in San Francisco and then again in Singapore 8 minutes later, that's suspicious. The haversine formula calculates the great-circle distance between two points on Earth given latitude and longitude. It's the foundation of velocity fraud checks.

fn haversine(lat1: f64, lon1: f64, lat2: f64, lon2: f64) -> f64 { const R: f64 = 6371.0; // Earth radius in km let d_lat = (lat2 - lat1).to_radians(); let d_lon = (lon2 - lon1).to_radians(); let a = sin(d_lat / 2.0).powi(2) + cos(lat1.to_radians()) * cos(lat2.to_radians()) * sin(d_lon / 2.0).powi(2); let c = 2.0 * atan2(sqrt(a), sqrt(1.0 - a)); R * c }

This runs in ~2 microseconds on typical hardware. Call it 500,000 times per second per user and you're still using less than 1ms of browser CPU. That's the constraint: fraud scoring must be fast enough that it never blocks user interaction.

Isolation Forest Scoring

Isolation forests are elegant. Instead of trying to define what fraud looks like, they find what's isolated. Features that fall far outside the normal distribution get high anomaly scores. They're especially good at catching the weird outliers that rule-based systems miss.

The algorithm trains offline on clean historical transactions, generates a forest of decision trees, then at prediction time you drop a new transaction through all trees and count how many times it hits a leaf quickly (indicating isolation). Quick isolation means high anomaly score.

fn fraud_score(&self, features: &[f64; 8]) -> f64 { let mut total_depth = 0; for tree in &self.trees { total_depth += tree.traverse(features); } let avg_depth = total_depth as f64 / self.trees.len() as f64; (avg_depth / 10.0).min(1.0) // normalized to [0, 1] }

For QuackNet, I baked 64 trees into the WASM module. Each tree is a compact binary format: a few hundred bytes per tree, so about 32KB raw data. After LZ4 compression, down to 8KB. Add the haversine and velocity code, trim dead code with wasm-gc, and we're at 6.3KB total.

Velocity: Movement Across Time

The haversine distances matter only in context. A 1000km jump is fine if 4 hours have passed. It's impossible if only 20 minutes passed. Velocity fraud checks look at the maximum distance divided by minimum time between transactions.

I track a sliding window of the last 10 transactions per user in localStorage. When a new transaction arrives, I compute the distance and time delta to each prior transaction, extract the maximum velocity (distance / time), and compare it to learned thresholds. That's another 2KB of code handling window logic and vectorized distance lookups.

Performance Numbers: The Real Cost

Here's what I measured on a 2023 MacBook Air M2 with the full fraudNet module:

  • WASM scoring: 50,000 fraud scores per second
  • JavaScript fallback: 5,000 fraud scores per second (I keep a JS implementation for older browsers)
  • Single fraud_score call: 20 microseconds (WASM), 200 microseconds (JS)
  • Velocity check (10 transactions): 8 microseconds (WASM)
  • Module load time: 3ms (instantiation + tree deserialization)
  • Total bundle size: 6.3KB (WASM binary)

The 10x performance gap over JavaScript isn't from optimization alone. It's from predictable memory layout, no garbage collection, and CPU-native floating point operations. JavaScript engines are fast, but they're not made for dense numerical code running under tight timing constraints.

Bundle Size: Keeping It Tiny

6.3KB sounds small until you realize it's being downloaded and instantiated for every new user. Each KB matters. I got here through relentless trimming:

  • No standard library (saved ~400KB)
  • Custom math functions instead of libm (saved ~300KB)
  • wasm-opt with aggressive optimization flags (saved 15%)
  • Baked-in tree data instead of runtime parsing (saved 8%)
  • LZ4 pre-compression of tree arrays (saved 60% of tree data size)

At 6.3KB, I ship the full isolation forest, haversine implementation, velocity logic, and a fallback distance cache to every browser. It loads in parallel with the UI and runs synchronously on the worker thread, never blocking the main thread.

Gotchas: What Bit Me

Debugging WASM is painful. Chrome DevTools can inspect the module, but you don't get line numbers or variable names. I had to add extensive logging to the JavaScript wrapper. Print debugging works, but it's slower than stepping through Rust code on the server.

Memory layout matters more than you think. I initially stored features in a JavaScript array and passed them to WASM as individual f64 arguments. Each call crossed the JS/WASM boundary 8 times. Moving to a pre-allocated typed array buffer cut boundary crossing overhead from 30% to 3% of total time.

Browser compatibility isn't guaranteed. WASM is widely supported now (95%+ of users), but I ship a JavaScript fallback anyway. The fallback is 50% slower but it works. I detect WASM support at load time and route accordingly. Worth the 3KB of extra code for the fallback.

Tree serialization format matters. I spent a day debugging why the JS implementation gave different scores than WASM. Turned out a floating point comparison was off by 0.0001 due to different precision handling. I standardized on always storing f32 in the tree arrays and converting to f64 at read time. Solved it.

Real-World Impact

QuackNet processes 50,000 transactions per day. Every one now gets a fraud score computed in the browser before it even reaches our backend. The browser score is advisory—backend fraud rules are still authoritative—but it lets us catch obvious fraudsters faster and send warnings to users immediately, before they finish checkout.

Most importantly, it moves compute to where the latency matters least. A 20 microsecond fraud calculation in the browser beats a 200 millisecond round trip to a backend server every time.

The lesson: Not all compute belongs on the server. When the algorithm is pure math, stateless, and sensitive to latency, shipping compiled code to the browser makes sense. Rust and WASM make it practical. The 6.3KB of compiled code processes more fraud signals per second than the 200KB JavaScript equivalent, and users see instant feedback instead of waiting for a network round trip.