RustWebAssemblyCloudflare WorkersWASIComponent Model
This is the follow-up post that gets philosophical at the end. You’ve been warned.
Last week we built a stateful coordinator at the edge using Durable Objects, including a Dynamic Worker sandbox for executing arbitrary code at runtime with millisecond cold starts and zero container overhead. We ended on a cliffhanger: all of that power, and we were still running JavaScript. Tonight we fix that.
Why Rust? (The Honest Version)
I’ll be upfront: I write Rust for personal projects partly because I enjoy the pain. The borrow checker has opinions, and sometimes those opinions are delivered at 11pm with 47 compiler errors. But there are real technical reasons Rust is the right language for WebAssembly:
- No garbage collector — WASM environments don’t manage memory for you. Rust’s ownership system handles it at compile time. The output is lean by construction.
- Small binary size — A Rust WASM module compiled with wasm-opt can be under 100KB. A comparable Go binary brings a 2MB+ runtime. Size matters at 300+ PoPs.
- Memory safety at compile time — Rust eliminates buffer overflows and use-after-free bugs before the code runs. You’re not hoping the sandbox catches bad memory access.
- Mature toolchain — wasm-pack, cargo-component, and worker-build are genuinely usable now. From
cargo newto deployed WASM Worker is a few commands.
Project Setup and Cargo Configuration
[package]
name = "edge-filter-engine"
version = "0.1.0"
edition = "2021"
[lib]
# cdylib = C-compatible dynamic library - required for WASM output
crate-type = ["cdylib"]
[dependencies]
worker = "0.4"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
wit-bindgen = "0.24"
[profile.release]
# These two settings reduce WASM binary size by 40-60%
opt-level = "s" # Optimize for size
lto = true # Link-time optimization - dead code elimination
strip = true # Strip debug symbols
# Install WASM compilation targets rustup target add wasm32-unknown-unknown # Edge / browser rustup target add wasm32-wasip1 # Server-side (WASI) # Install Cloudflare build tooling cargo install worker-build cargo install wasm-pack # Build the Cloudflare Worker worker-build --release # Build browser-facing WASM component wasm-pack build --target web --release # Build WASI server-side binary cargo build --target wasm32-wasip1 --release # Deploy the edge Worker wrangler deploy # Test the WASI binary locally wasmtime ./target/wasm32-wasip1/release/filter-engine.wasm
Your First Real Rust Worker
Skip Hello World. Here’s a transformation endpoint with byte-level string manipulation, SHA-256 hashing, and tracking parameter stripping — the kind of CPU-bound work where the language choice matters.
use worker::*;
use serde::{Deserialize, Serialize};
#[derive(Deserialize)]
struct TransformRequest { input: String, operation: String }
#[derive(Serialize)]
struct TransformResponse { output: String, bytes_in: usize, duration_ms: f64, operation: String }
#[event(fetch)]
async fn main(req: Request, env: Env, _ctx: Context) -> Result<Response> {
Router::new()
.post_async("/transform", handle_transform)
.get("/health", |_, _| Response::ok("healthy"))
.run(req, env).await
}
async fn handle_transform(mut req: Request, _ctx: RouteContext<()>) -> Result<Response> {
let start = js_sys::Date::now();
let payload: TransformRequest = req.json().await?;
let input_len = payload.input.len();
let output = match payload.operation.as_str() {
"uppercase" => payload.input.to_uppercase(),
"lowercase" => payload.input.to_lowercase(),
"reverse" => payload.input.chars().rev().collect(),
"strip_tracking" => strip_tracking_params(&payload.input),
op => return Response::error(format!("Unknown operation: {op}"), 400),
};
Response::from_json(&TransformResponse {
bytes_in: input_len, duration_ms: js_sys::Date::now() - start,
operation: payload.operation, output,
})
}
fn strip_tracking_params(url: &str) -> String {
// Byte-level manipulation - no heap surprises, no regex overhead, no GC pauses
const TRACKING: &[&str] = &[
"utm_source", "utm_medium", "utm_campaign", "utm_content", "utm_term",
"fbclid", "gclid", "mc_cid", "mc_eid", "_ga", "ref", "igshid",
];
let Some(q) = url.find('?') else { return url.to_string(); };
let base = &url[..q];
let clean: Vec<&str> = url[q+1..].split('&')
.filter(|p| !TRACKING.contains(&p.split('=').next().unwrap_or("")))
.collect();
if clean.is_empty() { base.to_string() } else { format!("{}?{}", base, clean.join("&")) }
}
The Filter Engine: Rust Doing What Rust Does Best
Rust enums make pattern types explicit and exhaustive — invalid states are unrepresentable. No null checks, no optional string sprawl, no runtime surprises. This is the module that carries through the rest of this series.
use worker::*;
use serde::{Deserialize, Serialize};
#[derive(Deserialize)]
struct FilterRequest { rules: Vec<String>, url: String }
#[derive(Serialize)]
struct FilterResult { matched: bool, matched_rule: Option<String>, duration_ms: f64, rules_tested: usize }
#[event(fetch)]
async fn main(mut req: Request, _env: Env, _ctx: Context) -> Result<Response> {
let start = js_sys::Date::now();
let input: FilterRequest = req.json().await?;
let rules_tested = input.rules.len();
let matched = evaluate_rules(&input.rules, &input.url);
Response::from_json(&FilterResult {
matched: matched.is_some(), matched_rule: matched,
duration_ms: js_sys::Date::now() - start, rules_tested,
})
}
// Rust enums make invalid states unrepresentable - no null checks, no Option sprawl
enum Pattern<'a> {
DomainBlock(&'a str), // ||ads.example.com^
Exception(&'a str), // @@||safe.example.com^
Substring(&'a str), // /tracking/
Prefix(&'a str), // |https://ads
}
impl<'a> Pattern<'a> {
fn matches(&self, url: &str) -> bool {
match self {
Pattern::DomainBlock(d) => url.contains(&format!("://{d}")) || url.contains(&format!(".{d}")),
Pattern::Exception(_) => false,
Pattern::Substring(s) => url.contains(*s),
Pattern::Prefix(p) => url.starts_with(*p),
}
}
fn is_exception(&self) -> bool { matches!(self, Pattern::Exception(_)) }
}
fn parse_rule(rule: &str) -> Option<Pattern<'_>> {
let r = rule.trim();
if r.starts_with('!') || r.is_empty() { return None; }
if let Some(rest) = r.strip_prefix("@@||") { return Some(Pattern::Exception(rest.trim_end_matches('^'))); }
if let Some(rest) = r.strip_prefix("||") { return Some(Pattern::DomainBlock(rest.trim_end_matches('^'))); }
if let Some(rest) = r.strip_prefix('|') { return Some(Pattern::Prefix(rest)); }
Some(Pattern::Substring(r))
}
fn evaluate_rules(rules: &[String], url: &str) -> Option<String> {
// Exceptions (allowlist) take priority - check first
for rule in rules {
if let Some(p) = parse_rule(rule) {
if p.is_exception() && p.matches(url) { return None; }
}
}
// Block rules
for rule in rules {
if let Some(p) = parse_rule(rule) {
if !p.is_exception() && p.matches(url) { return Some(rule.clone()); }
}
}
None
}
Calling the Rust Worker from TypeScript via Service Binding
Service Bindings let Workers call each other directly — same machine, same thread when possible, no network hop.
export interface Env {
FILTER_ENGINE: Fetcher; // Service Binding -> the Rust Worker
FILTER_LISTS: KVNamespace; // Compiled filter lists
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
const targetUrl = url.searchParams.get('url');
if (!targetUrl) return new Response('Missing url param', { status: 400 });
// Load filter rules from KV - read-heavy, eventually consistent, globally cached
const rules = await env.FILTER_LISTS.get<string[]>('rules:current', { type: 'json' });
if (!rules) return new Response('Filter list not found', { status: 503 });
// Call the Rust WASM Worker via Service Binding - same machine, no network hop
const filterResponse = await env.FILTER_ENGINE.fetch(
new Request('https://filter-engine/evaluate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ rules, url: targetUrl }),
})
);
const result = await filterResponse.json<{
matched: boolean; matched_rule: string | null;
duration_ms: number; rules_tested: number;
}>();
return Response.json({ url: targetUrl, blocked: result.matched, rule: result.matched_rule, engine: 'rust/wasm' });
},
};
The Performance Story (With Honest Numbers)
For I/O-bound operations the language is irrelevant. For CPU-bound work — cryptography, filter matching, data transformation — Rust/WASM is measurably faster. But the more important dimension is predictability. WASM executes at consistent speed from the first invocation, unlike V8’s JIT-dependent variance.
| Percentile | TypeScript Worker | Rust/WASM Worker |
|---|---|---|
| P50 | 0.9ms | ⚡ 0.7ms |
| P95 | 2.8ms | ⚡ 1.0ms |
| P99 | 9.2ms | ⚡ 1.2ms |
| Max | 47ms | ⚡ 3.1ms |
The WASM Component Model: When Binaries Talk to Each Other
Here’s the piece I think is most underappreciated. Make yourself a drink.
The WASM Component Model defines a way for WASM components to describe their interfaces in a language-neutral format using WIT (WASM Interface Types). A Rust component can be called from TypeScript — or Go, C++, Python — at the binary level, via a typed interface, without a serialization layer between them.
// The single source of truth for the component API.
// Language doesn't matter: Rust, TypeScript, Go, C++ - all bind to this.
package adblock:filter-engine@1.0.0;
interface filter {
record filter-result {
matched: bool,
matched-rule: option<string>,
duration-ns: u64,
rules-tested: u32,
}
evaluate: func(rules: list<string>, url: string) -> filter-result;
evaluate-batch: func(rules: list<string>, urls: list<string>) -> list<filter-result>;
}
world filter-engine { export filter; }
// wit_bindgen generates the glue code that wires Rust to the component model
wit_bindgen::generate!({
world: "filter-engine",
path: "filter-engine.wit",
});
use adblock::filter_engine::filter::{FilterResult, Guest};
struct FilterEngineImpl;
impl Guest for FilterEngineImpl {
fn evaluate(rules: Vec<String>, url: String) -> FilterResult {
let start = std::time::Instant::now();
let result = run_evaluate(&rules, &url);
FilterResult {
matched: result.is_some(),
matched_rule: result,
duration_ns: start.elapsed().as_nanos() as u64,
rules_tested: rules.len() as u32,
}
}
fn evaluate_batch(rules: Vec<String>, urls: Vec<String>) -> Vec<FilterResult> {
urls.iter().map(|url| Self::evaluate(rules.clone(), url.clone())).collect()
}
}
// Register the implementation - equivalent of "export default" in JS
export!(FilterEngineImpl);
import init, { evaluate, evaluate_batch } from './filter-engine.wasm';
let filterRules: string[] = [];
let wasmReady = false;
async function initFilter(): Promise<void> {
await init();
const data = await fetch('/api/filter-rules/current').then(r => r.json()) as { rules: string[] };
filterRules = data.rules;
wasmReady = true;
}
// Intercept fetch() - evaluate URLs before any network request fires
const originalFetch = window.fetch.bind(window);
window.fetch = async (input, init?) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : (input as Request).url;
if (wasmReady && shouldFilter(url)) {
// Direct WASM call - same binary as the edge filter worker
// The result comes back in microseconds, before the network even touches it
const result = evaluate(filterRules, url);
if (result.matched) {
console.debug(`[filter] Blocked: ${url} - rule: ${result.matched_rule}`);
return new Response(null, { status: 403, headers: { 'X-Blocked-By': 'filter-engine' } });
}
}
return originalFetch(input, init);
};
function shouldFilter(url: string): boolean {
try { return new URL(url).hostname !== window.location.hostname; }
catch { return false; }
}
initFilter();
subgraph Browser[“Browser Runtime”]
FE_JS[“TypeScript UI”]
FE_WASM[“filter-engine.wasm (Rust)”]
FE_JS –>|”Direct WASM call”| FE_WASM
end
subgraph Edge[“Cloudflare Edge (300+ PoPs)”]
EW[“Edge Worker (TypeScript)”]
EW_WASM[“filter-engine.wasm (same binary)”]
DO[“Durable Object (coordinator)”]
EW –>|”Service Binding”| EW_WASM
EW <–>|”state”| DO
end
subgraph Server[“Server”]
SRV[“wasmtime runtime”]
SRV_WASM[“filter-engine.wasm (same binary)”]
SRV –> SRV_WASM
end
WIT[“filter-engine.wit: single source of truth”]
WIT -..-> FE_WASM
WIT -..-> EW_WASM
WIT -..-> SRV_WASM
style Browser fill:#4c1d95,color:#fff
style Edge fill:#7c2d12,color:#fff
style Server fill:#374151,color:#fff
style WIT fill:#14532d,color:#fff
Server-Side WASM: The WASI Story
WASI — the WebAssembly System Interface — defines a standardized set of system interfaces (file I/O, networking, clocks) that WASM modules can use without caring about the OS underneath. Think of it as POSIX for the post-OS world. The same Rust filter engine, compiled to wasm32-wasip1, runs on a bare-metal server via wasmtime. Same binary logic. Different runtime. No code changes.
// Server-side WASM with WASI - same Rust code, different runtime
// Your binary doesn't know if it's running in wasmtime, wasmer,
// a browser WASM shim, or a cloud edge runtime. WASI abstracts it all.
use std::io::{self, BufRead, Write};
fn main() {
let stdin = io::stdin();
let stdout = io::stdout();
let mut out = io::BufWriter::new(stdout.lock());
for line in stdin.lock().lines() {
let line = line.expect("Failed to read line");
if line.trim().is_empty() { continue; }
let request: serde_json::Value = match serde_json::from_str(&line) {
Ok(v) => v,
Err(e) => { eprintln!("Parse error: {e}"); continue; }
};
let rules: Vec<String> = request["rules"]
.as_array().unwrap_or(&vec![])
.iter().filter_map(|v| v.as_str().map(String::from))
.collect();
let url = request["url"].as_str().unwrap_or("").to_string();
// This exact function is shared with the Workers and component model versions
// One Rust implementation. Three deployment targets.
let result = evaluate_rules(&rules, &url);
let response = serde_json::json!({
"matched": result.is_some(),
"matched_rule": result,
"rules_tested": rules.len(),
});
writeln!(out, "{}", response).expect("Write failed");
out.flush().expect("Flush failed");
}
}
Why This Matters for Privacy Software
The current model for privacy tooling involves interpretable JavaScript that ad networks can probe and route around, or centralized proxy servers that require trusting a third party. A WASM-based privacy stack changes the threat model:
- WASM filter engine in the browser: compiled binary, not interpretable source. Executes before network requests fire. Behavior not detectable by probing extension JavaScript.
- Same WASM module at the edge: filter runs at the network layer before payloads arrive.
- WASM Component Model: browser and edge components share the same compiled binary. One Rust codebase. One build. Deployed everywhere.
The Trajectory
| Timeframe | What Happens |
|---|---|
| Now – 18 months | WASM Workers for CPU-bound edge logic: cryptography, filter matching, data transformation. |
| 1 – 3 years | WASM Component Model tooling matures. Typed binary interfaces between frontend and backend become a normal architectural choice. |
| 3 – 5 years | WASI standardization makes WASM binaries portable across browser/edge/server/IoT. Write-once, run-everywhere — but actually, this time, without the 2002 Java overhead baggage. |
The browser becoming a WASM runtime that also runs JavaScript, rather than a JavaScript runtime that also runs WASM, is not a far-off prediction. It’s the current trajectory. And Rust, with its zero-cost abstractions and best-in-class WASM toolchain, is the right language to be there for it.
What’s Next
Next up: building the full privacy proxy pipeline I’ve been alluding to throughout this series. A Cloudflare Workers pipeline that intercepts requests, runs them through the Rust/WASM filter engine we built tonight, strips tracking parameters, rewrites headers, and logs exactly nothing. The full stack, in production, with real filter lists.
If that sounds like the kind of over-engineered Sunday project you’d do instead of watching a normal movie, subscribe and I’ll see you there.
The [K]nightly Build ships when the code compiles. Usually on schedule. Mostly.

Leave a Reply