Running WebAssembly at the CDN edge promised near-native performance and language flexibility, but the reality of deploying it across a globally distributed network is more nuanced than most blog posts suggest. After two years of migrating CPU-intensive edge logic — image transformation, custom authentication, and bot classification — from JavaScript isolates to compiled Wasm modules on Fastly and Cloudflare, I can tell you the performance gains are real, but only if you understand how edge runtimes compile, sandbox, and schedule your modules. This isn't about running a hello-world in a worker; it's about operating Wasm under strict memory, CPU, and cold-start constraints where every millisecond of TTFB matters.
Why Wasm Outperforms V8 Isolates for Compute-Heavy Edge Logic
JavaScript isolates, like those powering Cloudflare Workers and many proprietary CDN edge functions, are exceptionally fast for I/O-bound tasks. They start in under 5ms and handle asynchronous fetch, cache API, and header manipulation with minimal overhead. Where they struggle is with deterministic, CPU-bound computation. In my experience, any logic that requires heavy string parsing, cryptography beyond WebCrypto, image decoding, or regex at scale will hit the isolate's CPU wall time long before it hits memory limits.
Wasm changes the execution model entirely. Instead of being interpreted and JIT-compiled by V8, a Wasm module is a pre-compiled, statically-typed binary with linear memory and explicit imports. Edge platforms like Fastly Compute use Wasmtime under the hood to ahead-of-time (AOT) compile that Wasm into native code for the host architecture. The result is predictable execution time. On a recent project moving a custom bot signature classifier from JavaScript to Rust-compiled Wasm, we saw p95 CPU time drop from 42ms to 7ms on the same PoP, which directly reduced our 95th percentile TTFB by 28ms because we were no longer queuing behind the isolate's CPU limiter.
The isolation model is also fundamentally different. V8 isolates share a garbage-collected heap, which makes noisy-neighbor problems possible under high concurrency. Wasm modules operate in a contiguous, manually-managed linear memory array isolated per request. There is no GC pause to account for. I've found that this makes tail latency far more predictable during traffic spikes — a critical property when you run the same logic in 300+ points of presence simultaneously.
Where Wasm Doesn't Help
Wasm is not a universal replacement. If your edge logic is primarily fetch, cache lookups, and header rewrites, the overhead of Wasm instantiation and the lack of direct access to native Fetch or Cache APIs without WASI shims will actually make it slower. I keep I/O-heavy routing in JavaScript and reserve Wasm for the computational hot path. A pattern that works well is to use a thin JavaScript wrapper for request handling and call into a Wasm module for the expensive part, which I will cover below. For a broader comparison of the JavaScript-native approach, see Edge Functions Deep Dive: Cloudflare Workers, Deno Deploy and Vercel Edge.
From Rust to wasm32-wasip2: Compiling Modules That Survive Edge Constraints
The most common mistake I see teams make is compiling with the wrong target. For edge deployment in 2024+, you should be targeting wasm32-wasip2 (WASI Preview 2) instead of the older wasm32-wasi or wasm32-unknown-unknown. WASI Preview 2 introduces the component model, which defines how modules interact with hosts via WIT (Wasm Interface Types) rather than raw imports. This matters on the edge because hosts like Fastly and Fermyon no longer want to expose low-level WASI syscalls; they want to expose typed interfaces for HTTP, key-value stores, and logging.
Rust remains the most mature toolchain for this. Go and C# can compile to Wasm, but their GC and runtime inclusions inflate binary size past what is practical for a cache-cold PoP. My current Rust toolchain pins cargo-component 0.12+ and wit-bindgen for generating bindings. Keep your binary size under 2MB compressed; ideally under 1MB. Edge networks replicate modules to every PoP, and a 8MB binary will noticeably increase propagation delay and cold start time. I enforce this with wasm-opt -Oz and stripping debug symbols in CI.
// Cargo.toml for a Fastly Compute module using WASI Preview 2
[package]
name = "edge-classifier"
version = "1.0.0"
edition = "2021"
[dependencies]
fastly = "0.11"
serde = { version = "1.0", features = ["derive"] }
[profile.release]
opt-level = "z"
lto = true
strip = true
// Build command
// cargo build --target wasm32-wasip2 --release
// wasm-opt target/wasm32-wasip2/release/edge_classifier.wasm -Oz -o module.optimized.wasm
For the module interface, define your imports/exports in WIT. Fastly, for example, expects you to export an HTTP handler. Do not try to use std::fs or std::net inside your module — those WASI capabilities are intentionally not granted by the edge host. Any I/O must go through the host-provided interfaces (like fastly:compute/http). If you compile code that attempts to open a socket directly, it will trap at runtime, which is a difficult error to debug without local emulation via wasmtime serve.
Handling Dependencies and Binary Bloat
Audit your dependency tree with cargo bloat and cargo tree. Crates that pull in chrono, regex with Unicode features, or reqwest will explode your binary. I prefer time with minimal features, regex-lite when possible, and I avoid synchronous HTTP clients entirely. In one audit we cut a 3.4MB module down to 890KB just by replacing serde_json with simd-json features disabled and removing an unused aws-sdk crate that was pulled transitively.
Memory Sandboxing, Fuel Metering, and Cold Starts on Edge PoPs
Every edge platform enforces a hard limit on what your Wasm module can do, but they enforce it differently. Understanding this determines how you structure your code. Fastly Compute gives you a 64MB linear memory limit per instance and bills for CPU time via wall-clock time limits (typically 100ms default, configurable to several seconds on paid plans). Cloudflare Workers running Wasm has a 128MB memory limit but combines Wasm execution with the isolate's 50ms CPU time limit unless you are on an Enterprise plan with increased limits and use the Workers Paid tier.
The most important concept is fuel metering. Wasmtime-based runtimes don't measure time directly; they decrement a fuel counter per instruction. When fuel reaches zero, the instance traps. This is why deterministic performance matters. A Wasm module that parses a 1MB JSON payload will consume fuel linearly with payload size. I've found that you must implement early-exit logic for oversized inputs before they ever hit the Wasm linear memory. On the edge, you should reject or truncate at the host level (using host-provided header inspection) before invoking Wasm.
// Early validation in the host wrapper before calling Wasm
// This runs in the JS isolate / host, not in Wasm linear memory
export default {
async fetch(request, env) {
const contentLength = parseInt(request.headers.get('content-length') || '0', 10);
// Reject before instantiation cost
if (contentLength > 512 * 1024) {
return new Response('Payload too large for edge verification', { status: 413 });
}
// Instantiate once and reuse where runtime allows
const wasm = await WebAssembly.instantiate(wasmModule, importObject);
const result = wasm.exports.verify_token(request.headers.get('authorization'));
if (result === 0) {
return new Response('Unauthorized', { status: 401 });
}
return fetch(request);
}
}
Cold starts are the other variable. A PoP that hasn't received traffic for your service in 5-15 minutes will need to cold-start your Wasm instance. This involves downloading the module (if not cached on that PoP's SSD), AOT compiling it if not already cached as native code, and instantiating linear memory. For a 1MB module, I typically measure 12-25ms cold start on Fastly and 15-35ms on Cloudflare depending on region. Your module's _start function and static initializers run during this phase, so keep them trivial. Move heavy initialization, like building a regex set or loading a model, to a lazy-init path that only runs on the first real request and is cached in the module's global state.
Linear Memory Management Without a GC
Inside Wasm, you own memory. If you allocate a 10MB buffer for image processing and forget to free it, that memory stays allocated for the lifetime of that instance (which may be reused for subsequent requests on the same isolate host). I've seen memory leaks manifest as sporadic 502s when a PoP reuses a Wasm instance that has grown to its 64MB limit. Use Rust's ownership model to your advantage — avoid Box::leak and manual Vec::with_capacity without bounds. For temporary buffers, allocate on the stack or use a bump allocator that resets per request.
Wiring Wasm Modules Into the CDN Request Pipeline Without Adding Latency
The placement of your Wasm logic in the request lifecycle dictates its impact on cache hit ratio and origin shielding. There are three insertion points: before cache lookup, after cache miss (origin request), and after cache hit (origin response). For authentication and bot filtering, you want to run before cache lookup to avoid serving cached private content to unauthenticated users. For content transformation (HTML rewriting, image optimization), run after cache lookup to avoid polluting the cache with per-user variants.
I structure most edge Wasm services as a filter chain. The host runtime (Fastly's Viceroy or Cloudflare's workerd) invokes my handler for every request, but I branch early based on URL patterns and cache state to avoid invoking Wasm at all for static assets. This saves both CPU and fuel. For example, we only run our JWT validation Wasm for /api/* and /account/*, bypassing it entirely for /static/* which is served directly from cache. This pattern is closely related to how you would implement Edge Authentication: JWT Verification and Token Validation at CDN Layer but with the cryptographic verification offloaded to Wasm for a 5-10x speedup over pure JavaScript SubtleCrypto.
// Fastly Compute (Rust) - branching before Wasm-heavy work
use fastly::http::{StatusCode, header};
use fastly::{Request, Response};
fn main(mut req: Request) -> Result<Response, fastly::Error> {
// Fast path: bypass Wasm for cacheable static assets
if req.get_path().starts_with("/static/") || req.get_path().ends_with(".css") {
req.set_header(header::X_CACHE_BYPASS_WASM, "1");
return Ok(req.send("origin_backend"));
}
// Only invoke Wasm classifier for API requests
if req.get_path().starts_with("/api/") {
// verify_jwt is the imported Wasm function
match verify_jwt_wasm(req.get_header_str("Authorization").unwrap_or("")) {
Ok(claims) if claims_valid(&claims) => {
req.set_header("X-User-ID", claims.sub);
}
_ => {
return Ok(Response::from_status(StatusCode::UNAUTHORIZED)
.with_body("Invalid token"));
}
}
}
// Continue to cache lookup / origin
Ok(req.send("origin_backend"))
}
extern "C" {
fn verify_jwt_wasm(token_ptr: *const u8, token_len: usize) -> i32;
}
Latency budgeting is critical. If your origin p50 is 40ms and your edge budget for added logic is 10ms, you cannot spend 20ms in Wasm instantiation. To hit that budget, pre-warm instances. Both Fastly and Cloudflare allow you to keep instances warm by sending synthetic health-check traffic. I run a 1 RPS canary per region from an external monitor that exercises the Wasm path to keep the AOT cache hot. According to the HTTP Working Group Specifications on caching semantics (https://httpwg.org/specs/), responses generated at the edge should still respect Cache-Control and Vary semantics, so ensure your Wasm-generated responses set appropriate cache headers or you will inadvertently cache personalized content globally.
Streaming and Transforming Responses
Do not buffer entire responses in Wasm linear memory if you can stream. WASI Preview 2's HTTP interfaces support streaming bodies, but many early examples buffer everything into a Vec<u8>. For HTML assembly or Server-Side Includes, process the response as a stream of chunks. This keeps memory flat and allows you to start sending bytes to the client before the origin has finished. If you need dynamic assembly, consider composing Wasm transforms with Edge-Side Includes in Modern CDNs: Dynamic Content Assembly at the Edge rather than re-implementing a full parser in Wasm.
Runtime Selection: Comparing Fastly Compute, Cloudflare Workers Wasm, and Fermyon Spin
Choosing a runtime is less about raw speed and more about interface compatibility and operational constraints. I've deployed the same Rust module to three platforms, and the differences in WASI support, networking, and observability forced code forks that were not obvious at the start. The table below summarizes what I've actually encountered in production, not what marketing pages claim.
| Capability | Fastly Compute (Wasmtime) | Cloudflare Workers + Wasm | Fermyon Spin / SpinKube |
|---|---|---|---|
| WASI Support | WASI Preview 2 via Component Model, HTTP + KV interfaces | Partial WASI via WASI shim + JS imports, not full Preview 2 | Full WASI Preview 2, pluggable triggers (HTTP, Redis, Cron) |
| Language Host | Native Wasm only (no JS wrapper required) | JS isolate hosts Wasm module via WebAssembly API | Native Wasm, can run JS via JS component |
| Memory / CPU Limits | 64 MB memory, 100ms-2s wall time (configurable) | 128 MB total, 50ms CPU (free) / 30s wall (paid, includes I/O) | Configurable per trigger, limited by K8s/host resources |
| Cold Start (1MB module) | 12-25ms, AOT cached on SSD per PoP | 15-35ms, includes V8 isolate + Wasm compile | 20-50ms, higher if running outside CDN PoPs |
| State / Storage Access | Config Store, KV Store via host imports (typed) | Workers KV, R2, D1 via JS bindings passed to Wasm | Spin KV, SQLite via WASI interfaces |
| Observability | Fanout logging, StatsD, limited tracing | Workers Trace Events, Logpush, Wrangler tail | OTel tracing built-in, Prometheus metrics |
In practice, I recommend Fastly Compute when you need pure compute with minimal JS glue and tight integration with caching logic. The component model there feels most complete for edge use. Cloudflare is better if you already have a large investment in Workers and need to incrementally accelerate a hot function with Wasm without rewriting the entire worker. Fermyon Spin is powerful for self-hosting at the edge or in your own points of presence, especially when you want to run the same Wasm module on the CDN and on your origin Kubernetes cluster.
One nuance: Cloudflare's Wasm support requires you to import the Wasm module as a binary asset via Wrangler. You cannot dynamically fetch and compile a module at runtime due to the lack of WebAssembly.compileStreaming with untrusted bytes in that environment. Fastly expects you to upload the compiled .wasm via their CLI or API, which then goes through their own AOT pipeline. This affects your deployment strategy — you cannot hot-swap modules via KV without a full service deploy on either platform, unlike some experimental runtimes that allow URL-imported Wasm. For broader context on function-based routing that often precedes a Wasm migration, see Geolocation Routing at the Edge: Country-Level Logic Without Redirects.
Observability and Failure Modes When Wasm Executes Across 300 PoPs
Debugging Wasm at the edge is fundamentally harder than debugging a container. You cannot SSH into a PoP, you cannot attach a debugger to a running instance, and stack traces from a Wasm trap are often truncated to a hex offset. In my experience, you need to build observability into the module itself from day one. I instrument every Wasm module with three things: structured logging via the host's logging interface, explicit error codes returned to the host instead of traps, and per-request metrics emitted via a StatsD or OTel interface if the host supports it.
A common failure mode is a non-determinism bug that only appears in one region. Wasm itself is deterministic, but your host imports are not. Clock skew, different dictionary values in Config Stores across PoPs due to propagation delay, and inconsistent request header casing can cause a Wasm module to behave differently in Singapore vs. Frankfurt. I once spent two days chasing a JWT verification failure that only occurred in one PoP — the cause was a legacy origin that sent content-length with a trailing space, which our Rust parser rejected, but only traffic from that region hit that origin shard. Local testing with wasmtime will never catch this because it mocks the host environment too cleanly.
What to Log and What to Sample
Do not log every request's Wasm execution details — you will exceed logging quotas and add latency. Instead, emit a sampled log line (1% by default, 100% for errors) that includes: Wasm fuel consumed, linear memory size at exit, execution time in microseconds, and a module version hash. This lets you correlate performance regressions to a specific binary version. For errors, return a typed error to the host and let the host decide to log and return a 500. Never allow a Wasm trap to bubble as a 500 without logging the trap code — traps for unreachable or out-of-bounds memory access are almost always bugs in unsafe code or in generated bindings.
Testing strategy also needs to adapt. Unit test your Rust logic natively with cargo test, but also run integration tests against the actual Wasm artifact using wasmtime run --wasi preview2 with mocked host functions. The web.dev Articles on WebAssembly performance (https://web.dev/articles/webassembly) provide excellent baseline data on instantiation cost and memory overhead that you can use to benchmark your own modules before they ever reach a PoP. Finally, implement a kill switch: a Config Store flag that lets you bypass the Wasm path entirely and fall back to a safe, slower JavaScript or origin path without redeploying the module. When a Wasm module traps globally, you need a way to disable it in seconds, not minutes.
Frequently Asked Questions
Can I use WebAssembly to run existing Node.js libraries at the edge?
Generally no, at least not directly. Most Node.js libraries depend on Node APIs (fs, net, crypto) that are not available as WASI imports on CDN edge hosts. You can compile JavaScript to Wasm via QuickJS or Javy, but the result is still interpreted JavaScript inside Wasm and offers little performance benefit. The practical path is to port the specific algorithm to Rust or AssemblyScript and compile that to Wasm, keeping your existing JS for orchestration.
How do WASI Preview 2 components differ from older wasm32-wasi modules?
WASI Preview 2 introduces the component model with WIT-defined interfaces, allowing modules to import typed functions like http:handle or kv:get instead of raw POSIX-like syscalls. Older Preview 1 modules used fd_read and sock_open which edge hosts intentionally do not implement. If you deploy a Preview 1 module to a Preview 2 host like Fastly Compute, it will fail to instantiate due to missing imports. Always build with wasm32-wasip2 for current edge platforms.
Will a Wasm module increase my cache hit ratio or decrease it?
It depends on placement. If you run Wasm before cache lookup and vary the cache key based on Wasm output (for example, personalizing HTML), your hit ratio will decrease because you create more cache variants. If you run Wasm after a cache hit to transform a cached response (like injecting a user name), hit ratio is unaffected. I recommend running expensive Wasm logic after cache lookup whenever possible and using Vary headers carefully to avoid cache fragmentation.
What is the realistic performance overhead of calling JavaScript from Wasm and vice versa?
The cross-boundary call itself is cheap — typically under 1 microsecond — but passing data across the boundary is expensive. Every string or buffer must be copied between the JS isolate's heap and Wasm linear memory, which involves encoding (UTF-8) and allocation. For high-throughput paths, minimize round-trips and pass data as raw bytes with length pointers rather than JSON strings. In benchmarks, a single large JSON copy across the boundary can cost more than the actual Wasm computation you were trying to accelerate.