choose Wasm over containers for latency-sensitive edge logic

WebAssembly at the edge: when Wasm beats containers for serverless and CDN logic

13 min read

Containers still own long-lived services, but edge PoPs need sub-millisecond starts and tiny binaries. Wasm runtimes on Cloudflare Workers, Fastly Compute, and Spin give sandboxed, request-scoped compute—use them for auth gates, routing, and transforms, not for databases.

Why containers struggle on CDN edge nodes

Edge platforms place logic in points of presence close to users. Responses often need to land under tens of milliseconds. A container or classic FaaS cold start measured in hundreds of milliseconds to seconds breaks personalization, A/B assignment, auth checks, and API routing at the edge. Minimal Node or Python images still reserve tens of megabytes; thousands of concurrent isolates across a global PoP fleet waste memory. Containers share a host kernel—even with gVisor or Kata the isolation story is heavier than a Wasm sandbox designed for multi-tenant guests. Shipping a full OS userspace for a header rewrite or JWT gate means large images and slow deploys. Wasm does not replace Kubernetes for stateful backends; it complements containers where the unit of work is a short, sandboxed request.

How Wasm runs at the edge: runtimes, WASI, and components

WebAssembly began in the browser. WASI extends it for servers with explicit capabilities instead of ambient OS access. WASI Preview 1 remains common for simple modules; Preview 2 and the Component Model stabilize typed interfaces via WIT so hosts and guests compose like LEGO bricks. Edge nodes typically host a Wasm runtime—V8 isolates on Cloudflare Workers, Lucet lineage and Wasmtime-class engines on Fastly, Spin and Wasmtime for self-hosted or Akamai-style deployments. Modules start in microseconds to low milliseconds because there is no guest kernel boot. Memory is linear and bounded. You compile Rust, C, C++, Zig, and some higher-level languages to Wasm; Go often produces larger binaries because of its runtime. Portability across hosts is improving but not magic: Cloudflare Workers Rust uses wasm32-unknown-unknown plus workers-rs, while Spin and many WASI tools use wasm32-wasip1 or component toolchains.

Decision guide: Wasm versus containers

Prefer Wasm when the workload is stateless or stores state only in KV, Durable Objects, Redis, or origin services; when cold start and binary size dominate cost; when you need strong sandboxing for untrusted or multi-tenant plugins; and when the logic is request-scoped middleware—auth, routing, rewrite, feature flags, bot score gates. Prefer containers when you need a full Linux ABI, local disks, databases, language ecosystems that do not compile cleanly to Wasm, long-running workers, or GPU and specialized hardware. Execution speed of optimized Wasm often sits near native for tight loops; the decisive wins are density and startup. Treat Wasm as a specialized runtime next to your container platform, not a wholesale replacement.

Example: JWT edge gate on Cloudflare Workers with Rust

Cloudflare documents Rust Workers through the workers-rs crate and the wasm32-unknown-unknown target—not a homemade C ABI over environment variables. Scaffold with cargo generate cloudflare/workers-rs, then implement a fetch handler that rejects missing Bearer tokens, optionally decodes claims for enrichment, and fails closed on parse errors. In production verify signatures with a proper JWT library and keys from Secrets or KV; the snippet below keeps decoding illustrative. worker-build plus Wrangler produce the JS shim and optimized Wasm bundle.

Bash · scaffold and add the Workers Wasm target
#!/usr/bin/env bash
set -euo pipefail
rustup target add wasm32-unknown-unknown
cargo install cargo-generate worker-build
cargo generate cloudflare/workers-rs --name edge-auth-worker
cd edge-auth-worker
Rust · fetch handler with Bearer JWT gate
use base64::{
    engine::general_purpose::URL_SAFE_NO_PAD,
    Engine,
};
use serde::Deserialize;
use worker::*;

#[derive(Deserialize)]
struct JwtClaims {
    sub: String,
    exp: Option<u64>,
    scope: Option<String>,
}

#[event(fetch)]
async fn main(req: Request, _env: Env, _ctx: Context) -> Result<Response> {
    let auth = match req.headers().get("Authorization")? {
        Some(value) => value,
        None => return Response::error("Missing Authorization", 401),
    };

    let token = match auth.strip_prefix("Bearer ") {
        Some(value) if !value.is_empty() => value,
        _ => return Response::error("Invalid Authorization", 401),
    };

    let claims = match decode_claims(token) {
        Ok(claims) => claims,
        Err(_) => return Response::error("Invalid or expired token", 403),
    };

    // Production: verify signature with a JWT library, then proxy to origin.
    Response::from_json(&serde_json::json!({
        "ok": true,
        "user": claims.sub,
        "scope": claims.scope,
    }))
}

fn decode_claims(token: &str) -> Result<JwtClaims> {
    let mut parts = token.split('.');
    let (_header, payload, _sig) = match (parts.next(), parts.next(), parts.next(), parts.next()) {
        (Some(h), Some(p), Some(s), None) if !h.is_empty() && !p.is_empty() && !s.is_empty() => {
            (h, p, s)
        }
        _ => return Err(Error::RustError("malformed jwt".into())),
    };

    let bytes = URL_SAFE_NO_PAD
        .decode(payload)
        .map_err(|_| Error::RustError("jwt payload decode".into()))?;
    let claims: JwtClaims =
        serde_json::from_slice(&bytes).map_err(|_| Error::RustError("jwt json".into()))?;

    if let Some(exp) = claims.exp {
        let now = Date::now().as_millis() as u64 / 1000;
        if exp < now {
            return Err(Error::RustError("expired".into()));
        }
    }

    Ok(claims)
}
TOML · Cargo.toml dependencies for the Worker
[package]
name = "edge-auth-worker"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib"]

[dependencies]
worker = "0.5"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
base64 = "0.22"

[profile.release]
lto = true
strip = true
codegen-units = 1
TOML · wrangler.toml with worker-build
name = "edge-auth-worker"
main = "build/worker/shim.mjs"
compatibility_date = "2026-01-01"

[build]
command = "cargo install -q worker-build && worker-build --release"

[[routes]]
pattern = "api.example.com/*"
zone_name = "example.com"

Portable WASI builds for Spin and self-hosted runtimes

When you target Spin, Wasmtime, or other WASI hosts, use the current Rust target name wasm32-wasip1 (wasm32-wasi was renamed). Build release modules, then strip and optimize with Binaryen's wasm-opt. Fastly Compute and Spin lean on WASI HTTP worlds and components; keep platform-specific glue thin so business logic stays portable. Expect host capability differences—filesystem, sockets, and clocks are granted explicitly. Test the same module against the runtime you ship, not only against unit tests on the host architecture.

Bash · build and optimize a WASI module
#!/usr/bin/env bash
set -euo pipefail
rustup target add wasm32-wasip1
cargo build --target wasm32-wasip1 --release
wasm-opt -O3 --strip-debug --strip-producers \
  target/wasm32-wasip1/release/edge_auth_worker.wasm \
  -o dist/edge-auth-worker.wasm
ls -lh dist/edge-auth-worker.wasm

Production practices: size, state, observability, security

Keep dependency graphs small; enable LTO and strip in release profiles; run wasm-opt before upload so bundles stay in the tens or low hundreds of kilobytes for simple gates. Prefer Rust or C for the hottest paths; Go Wasm often ships megabytes of runtime. Put sessions and caches in Cloudflare KV, Durable Objects, Redis, or origin APIs—guest Wasm should not pretend to be a database. Emit structured logs and platform metrics (Workers Analytics Engine, Fastly logging, OpenTelemetry where supported); edge nodes otherwise hide failures. Avoid unsafe in Rust guests; audit C/C++; validate every header and body; set memory and CPU limits per request. On errors, fail closed for auth and fail open to origin only when product policy allows passThroughOnException-style behavior.

Roll out gradually and keep containers in the architecture

Migrate one middleware first—auth, bot filtering, or geo routing—behind a percentage split or separate hostname. Compare p99 latency, error rate, and cost per million requests against the previous container or Lambda path. Expand only after dashboards stay green. Sign and inventory Wasm artifacts like any other supply-chain artifact. Containers and VMs remain the right home for APIs with heavy frameworks, data planes, and batch jobs; standardize those runtimes separately while Wasm owns the ultra-dense edge layer. The operational habit is selective: default to containers for full services, choose Wasm when cold start, density, and sandboxing dominate the requirements.

Gradual Wasm rollouts pair with progressive delivery patterns from our canary and feature-flag guide for Kubernetes.

Containers remain the default for full applications—see how to standardize them in our guide to standardizing container and VM operations.