background-shape
Running Wasm at the Edge, Cloudflare Workers and Fastly Compute
February 26, 2025 · 11 min read · by Muhammad Amal programming

TL;DR — Cloudflare Workers and Fastly Compute are the two production Wasm edge platforms in February 2025. Workers uses V8 with Wasm modules; Compute uses pure Wasmtime. Pick Workers for JS-first or worldwide-KV. Pick Compute for pure Rust, predictable cold starts, and stricter sandboxing.

Wasm at the edge is where the cold-start argument stops being a slide and becomes the actual reason you’d ship the platform. When your code has to run in 200+ points of presence and start under 5 ms, containers aren’t on the menu. Both Cloudflare and Fastly figured this out years ago, made very different bets, and the two platforms now define the practical shape of edge Wasm.

This article is a head-to-head plus enough working code that you can deploy either one this afternoon. I’m assuming you’ve worked through at least the cargo-component tutorial or otherwise know your way around cargo and wasm32 targets. If you want the larger context, the server-side Wasm survey is the starting point.

A note on positioning. Cloudflare Workers also runs JavaScript (and TypeScript, Python via Pyodide, etc.); we’ll focus on the Rust-via-Wasm story, which is what most backend engineers reach for. Fastly Compute is Wasm-only by design.

Architecture Differences

The two platforms diverge in the runtime layer.

Cloudflare Workers                Fastly Compute
+---------------------+           +----------------------+
| V8 isolate          |           | wasmtime + Lucet     |
|  + JS/TS runtime    |           |  pure Wasm           |
|  + Wasm modules     |           |  AOT compiled        |
|    (called from JS  |           |  per-request         |
|     or directly via |           |  instance            |
|     workers-rs)     |           +----------------------+
+---------------------+
| Durable Objects     |           | KV Store, Config     |
| Workers KV          |           | Store, Object Store  |
| D1, R2, Queues      |           | Cache API            |
+---------------------+           +----------------------+
| Tail Workers, Logs  |           | Real-time logs       |
+---------------------+           +----------------------+

Workers runs on V8 isolates. JS is first class, Wasm runs inside the isolate as a module callable from JS or via the workers-rs SDK which provides Rust ergonomics on top. The benefit: shared event-loop with the V8 JS runtime, integrated Fetch API, big ecosystem.

Fastly Compute runs pure Wasmtime (formerly Lucet, now AOT-compiled Wasmtime). No V8, no JS runtime. Each request gets its own instance, instantiated from a precompiled artifact, executed, destroyed. Cold-start is sub-millisecond. Memory caps are strict.

Which one matters for you depends on what you value. I’ll break it down below.

Cloudflare Workers, Rust on Workers

Step 1, Install Wrangler

npm install -g wrangler@3.78.0
wrangler --version
# 3.78.0

rustup target add wasm32-unknown-unknown
cargo install worker-build --version 0.1.0

wasm32-unknown-unknown is the target. Workers wraps the Wasm module in JS glue. You’re not shipping a WASI component here; you’re shipping a Wasm module that V8 loads.

Step 2, Scaffold

wrangler init --type rust edge-links
cd edge-links

Cargo.toml:

[package]
name = "edge-links"
version = "0.1.0"
edition = "2021"

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

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

Step 3, Write the Handler

src/lib.rs:

use worker::*;
use serde::{Deserialize, Serialize};

#[derive(Deserialize)]
struct ShortenReq { url: String }

#[derive(Serialize)]
struct ShortenResp { slug: String, short_url: String }

#[event(fetch)]
async fn fetch(req: Request, env: Env, _ctx: Context) -> Result<Response> {
    let router = Router::new();
    router
        .post_async("/shorten", |mut req, ctx| async move {
            let body: ShortenReq = req.json().await?;
            if !body.url.starts_with("http") {
                return Response::error("url must be http(s)", 400);
            }
            let slug = random_slug(6);
            let kv = ctx.kv("LINKS")?;
            kv.put(&slug, &body.url)?.execute().await?;
            let host = req.headers().get("host")?.unwrap_or_default();
            let resp = ShortenResp {
                slug: slug.clone(),
                short_url: format!("https://{host}/{slug}"),
            };
            Response::from_json(&resp)
        })
        .get_async("/:slug", |_req, ctx| async move {
            let slug = ctx.param("slug").unwrap();
            let kv = ctx.kv("LINKS")?;
            match kv.get(slug).text().await? {
                Some(url) => Response::redirect_with_status(Url::parse(&url)?, 302),
                None => Response::error("not found", 404),
            }
        })
        .get("/health", |_req, _ctx| Response::ok("ok"))
        .run(req, env)
        .await
}

fn random_slug(len: usize) -> String {
    use rand::Rng;
    const C: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789";
    let mut rng = rand::thread_rng();
    (0..len).map(|_| C[rng.gen_range(0..C.len())] as char).collect()
}

Step 4, Configure Wrangler

wrangler.toml:

name = "edge-links"
main = "build/worker/shim.mjs"
compatibility_date = "2025-02-15"

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

[[kv_namespaces]]
binding = "LINKS"
id = "your-kv-namespace-id"

Step 5, Develop and Deploy

wrangler kv:namespace create LINKS
# paste the id into wrangler.toml

wrangler dev    # local dev with Miniflare
# test against http://127.0.0.1:8787

wrangler deploy
# Published edge-links (1.2 sec)
# https://edge-links.<your-account>.workers.dev

You now have a worker running in 200+ Cloudflare PoPs with sub-5ms cold start. KV is eventually consistent and global. R2 is available if you need bulk storage. D1 if you need SQL.

Fastly Compute, Pure Wasm

Step 1, Install the Fastly CLI

brew install fastly/tap/fastly
fastly version
# Fastly CLI version v10.16.0

Step 2, Scaffold

mkdir compute-links && cd compute-links
fastly compute init --language rust --from default
# follow prompts

Cargo.toml:

[package]
name = "compute-links"
version = "0.1.0"
edition = "2021"

[dependencies]
fastly = "0.11"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
rand = "0.8"

Step 3, Write the Handler

src/main.rs:

use fastly::http::{Method, StatusCode};
use fastly::{Error, Request, Response};
use fastly::kv_store::KVStore;
use serde::{Deserialize, Serialize};

#[derive(Deserialize)]
struct ShortenReq { url: String }

#[derive(Serialize)]
struct ShortenResp { slug: String, short_url: String }

#[fastly::main]
fn main(req: Request) -> Result<Response, Error> {
    match (req.get_method(), req.get_path()) {
        (&Method::POST, "/shorten") => shorten(req),
        (&Method::GET, "/health") => Ok(Response::from_body("ok")),
        (&Method::GET, p) if p.len() > 1 => redirect(req, p.trim_start_matches('/')),
        _ => Ok(Response::from_status(StatusCode::NOT_FOUND)),
    }
}

fn shorten(mut req: Request) -> Result<Response, Error> {
    let body: ShortenReq = serde_json::from_slice(&req.take_body_bytes())?;
    if !body.url.starts_with("http") {
        return Ok(Response::from_status(StatusCode::BAD_REQUEST)
            .with_body("url must be http(s)"));
    }
    let slug = random_slug(6);
    let store = KVStore::open("links")?.expect("kv store");
    store.insert(&slug, body.url.as_bytes())?;
    let host = req.get_header_str("host").unwrap_or("");
    let resp = ShortenResp { slug: slug.clone(), short_url: format!("https://{host}/{slug}") };
    Ok(Response::from_status(StatusCode::OK)
        .with_content_type(fastly::mime::APPLICATION_JSON)
        .with_body(serde_json::to_vec(&resp)?))
}

fn redirect(_req: Request, slug: &str) -> Result<Response, Error> {
    let store = KVStore::open("links")?.expect("kv store");
    match store.lookup(slug)? {
        Some(item) => {
            let url = String::from_utf8(item.into_body_bytes())?;
            Ok(Response::from_status(StatusCode::FOUND).with_header("location", url))
        }
        None => Ok(Response::from_status(StatusCode::NOT_FOUND)),
    }
}

fn random_slug(len: usize) -> String {
    use rand::Rng;
    const C: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789";
    let mut rng = rand::thread_rng();
    (0..len).map(|_| C[rng.gen_range(0..C.len())] as char).collect()
}

Step 4, Configure and Deploy

fastly.toml:

authors = ["askmuhammadamal@gmail.com"]
description = "URL shortener at the edge"
language = "rust"
manifest_version = 3
name = "compute-links"
service_id = ""

[local_server]
[local_server.kv_stores.links]
file = "links.json"
# Provision the KV store at Fastly
fastly kv-store create --name links

# Local dev
fastly compute serve
# Listening on http://127.0.0.1:7676

# Deploy
fastly compute deploy
# Linking your project to a new service: compute-links
# Deployed package (28.4 KB)
# https://prod.compute-links.global.ssl.fastly.net

That’s a real edge service in tens of seconds of build time.

Comparing the Platforms

I’ve shipped both. Here’s the honest comparison.

Cold Start

Both are excellent. Workers is typically under 5 ms p99. Compute is typically under 1 ms p99 because the pure Wasm path skips V8 isolate setup. Either is plenty for HTTP request workloads. The difference matters if you’re building auth middleware where every hop’s startup adds latency.

Memory and CPU Limits

Workers Free: 128 MB memory, 10 ms CPU time. Paid: 128-512 MB, configurable CPU up to 30s. Compute: 128 MB by default, configurable; CPU is generous (configurable, including wall-clock).

Workers’ “CPU time” excludes time waiting on subrequests. Compute’s wall-clock budget includes everything. Different mental models.

Storage

Workers has a richer storage menu: KV (eventually consistent), Durable Objects (strongly consistent, single-region pinned), R2 (object), D1 (SQL), Queues. Compute has KV Store, Config Store, Object Store (S3-compatible), and the Edge Cache. Workers wins on breadth here. Compute is improving fast.

Languages

Workers: JS, TS, Rust (via Wasm), Python (Pyodide), and the rest of the JS ecosystem. Compute: Rust, JavaScript (via Wizer-precompiled JS), Go (TinyGo). Workers has a bigger language story by virtue of V8.

Pricing

Workers’ free tier is generous (100k requests/day). Compute requires a paid plan but with very generous request budgets. For low-traffic projects Workers is the cheaper start. At scale they converge.

Pick Workers when

You’re already on the Cloudflare ecosystem; you need Durable Objects’ strong consistency; you have JS/TS code mixed with Rust; you want the broadest storage menu.

Pick Compute when

Cold-start determinism is critical; you want pure Wasm without V8; your team is Rust-only; you care about Fastly’s network for content-heavy workloads.

Edge Caching Patterns

Both platforms expose a request-level cache that you should use aggressively. On Workers, caches.default is the global cache; you put a Response and key by URL+method. On Compute, the cache API is richer (you can specify TTL, surrogate keys, vary headers) and integrates with Fastly’s traditional CDN cache so cached responses serve from the edge without invoking your Wasm at all.

Pattern I use for both: pure GETs go through the cache with a 60-second TTL and a surrogate-key of the resource type. POSTs invalidate by surrogate key. The result is that 90%+ of read traffic never reaches the Wasm function, and the function itself only handles writes and cold reads. Latency at the edge becomes “did we hit cache” rather than “how fast is our Wasm.”

For dynamic content where pure caching doesn’t apply, look at stale-while-revalidate semantics. Both platforms support serving a cached response while asynchronously refreshing it. The user sees milliseconds. The refresh runs in the background.

Cache invalidation across PoPs is the hard part. Workers KV propagates writes globally in about a minute. If you need faster invalidation, use Durable Objects (Workers) or surrogate-key purges (Compute) and accept the cross-region coordination latency. There’s no magic; the same CAP-theorem tradeoffs apply at the edge as in any distributed system.

Common Pitfalls

Using std::time::SystemTime and being surprised. Both platforms restrict time access. Workers exposes Date::now() style, Compute lets you read time via the WASI clock APIs. Don’t reach for the standard Rust time API and expect it to “just work” cross-platform.

Hitting the request body size cap. Workers caps free at 100 MB, paid varies. Compute defaults around 16 MB with config knobs. Don’t try to proxy multi-gigabyte uploads through edge functions.

Forgetting that KV is eventually consistent. Workers KV propagates writes globally within ~60s. If you write then immediately read from a different PoP, you may see the old value. Use Durable Objects (Workers) or a stronger backend if you need read-your-writes.

Bundling huge dependencies. Both platforms have artifact size limits (~1 MB compressed on Workers paid, up to 50 MB on Compute). Run wasm-opt -Os, strip debug info, and pick lean crates. reqwest is too heavy; both platforms have native HTTP clients.

Troubleshooting

Wrangler dev fails with “wasm-bindgen out of date”. Pin worker-build and worker crate versions. Bumps frequently break the JS glue. Lock with --locked.

Compute build slow. First build downloads the SDK and tooling; subsequent builds are fast. If CI is repeatedly slow, cache ~/.cargo and target/ between runs.

KV reads return None after writes. Eventual consistency. Confirm with a delay loop in dev. For deterministic tests, use a per-test KV namespace or fall back to Durable Objects. The wasmtime docs explain runtime mechanics that underlie Compute; Workers’ docs at developers.cloudflare.com explain V8 isolates.

Observability at the Edge

Edge functions are hard to debug because the code runs in many places, requests can fail in one PoP and succeed in another, and you don’t ssh into edge nodes. Both platforms try to fill this gap with platform-specific telemetry.

Workers has Tail Workers, which let you attach a worker that consumes the trace events of another worker. Logs, exceptions, and outcomes show up in real time. The recent Workers Logs feature provides longer-term retention and search. Workers Analytics Engine is the metrics surface. None of it is OTel-native, but the proprietary tooling is good.

Compute has real-time logs streaming, structured logging via the standard log crate when configured with the Fastly target, and a metrics push to Datadog, New Relic, and other partners. There’s an OTel integration in beta as of early 2025. For request-level tracing, Compute supports W3C trace context propagation natively.

The cross-platform play is to emit structured logs in JSON format with a request ID, and ship them to your existing log aggregator via the platform’s logging integrations. That gets you 80% of what you need without writing platform-specific code. For richer tracing, accept that you’ll have some platform-specific glue and budget for it.

Synthetic monitoring matters more at the edge than in conventional deployments because you’re running in many PoPs and partial failures are common. Run synthetic checks from outside the platform (Pingdom, your own checker on a different cloud) to catch regional outages quickly.

Wrapping Up

Edge Wasm is the workload shape where Wasm’s pitch sells itself. Either Cloudflare Workers or Fastly Compute will get a real service into production faster than any container-based alternative. Pick by ecosystem fit and storage needs, not by marketing. With this and the rest of the February series, you have the full picture of where server-side Wasm sits in 2025: runtimes, frameworks, distributed platforms, container comparison, the component model, and the edge. The standards at component-model.bytecodealliance.org are the spine that ties it all together. Use it.