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.