The speed trap and the sandbox wall
You write a function that parses a JSON payload, transforms some data, and returns a response. In Python, the cold start drags on for two seconds while the interpreter initializes. In Node, the memory footprint feels heavy and the startup takes half a second. You want the speed of Rust without managing servers. You compile to WebAssembly and ship the binary to Cloudflare Workers or Fastly Compute@Edge. The promise is instant startup and tiny binaries. The reality is a compiler fight against the standard library.
Rust compiled to WASM starts in microseconds. The binary is small. The provider loads it instantly. You get the performance without the server. The catch is the sandbox. WebAssembly runs in a restricted environment. There is no file system. There are no TCP sockets. There are no threads. Rust's standard library assumes an operating system. It tries to open files and make network calls using OS syscalls. Those syscalls do not exist in the WASM sandbox. The compiler will reject your code, or the runtime will crash.
You solve this by stripping the standard library and using a provider-specific crate. The crate provides the bridge. It exports your functions to the host environment. The host handles the network and storage. Your code just processes data.
How WASM changes the rules
WebAssembly is a binary format that runs in a sandbox. The serverless provider runs a WASM runtime. Your Rust code becomes a .wasm file. The runtime calls your exported functions. The catch is that WASM has no OS. No file system, no TCP sockets, no threads. Rust's standard library assumes an OS. You have to strip it out.
The target triple wasm32-unknown-unknown tells the compiler to generate WASM bytecode for a generic host. The unknown-unknown part means there is no standard library implementation. You cannot use std::fs or std::net. You must use no_std compatible crates or provider-specific abstractions.
The community convention is to disable default features immediately. Many crates enable std by default. Disabling features forces you to pick only what works in WASM. It saves you from hidden dependencies that pull in mio or libc. Treat default-features = false as a shield. It blocks bad dependencies before they compile.
Minimal handler
You need a library crate, not a binary. The output must be a dynamic library that the WASM runtime can load. You set crate-type = ["cdylib"] in Cargo.toml. You add the worker crate for Cloudflare Workers. The worker crate handles the glue code to export functions to the host.
// Cargo.toml
[lib]
crate-type = ["cdylib"] // Export for WASM runtime
[dependencies]
worker = { version = "0.3", default-features = false } // Provider SDK
The handler function takes a request and returns a response. The worker crate provides a macro to mark the entry point. The macro generates the export code.
// src/lib.rs
use worker::*;
/// Entry point for the worker.
/// The provider calls this function for every request.
#[event(fetch)]
async fn main(req: Request, env: Env, ctx: Context) -> Result<Response> {
// Return a simple response
Response::ok("Rust WASM is fast")
}
The #[event(fetch)] macro marks the function as the fetch handler. The provider routes HTTP requests to this function. The Request and Response types come from the worker crate. They wrap the host's request and response objects. The Result type handles errors. If the function returns an error, the provider returns a 500 response.
You compile the code with cargo build --target wasm32-unknown-unknown --release. The output is a .wasm file in target/wasm32-unknown-unknown/release/. You upload this file to the provider. The provider configures the function name as the entry point.
What happens under the hood
When you call cargo build, the compiler generates WASM bytecode. The bytecode contains the logic and metadata. The metadata includes the exported functions. The worker macro adds exports for fetch and other events. The runtime loads the module and instantiates it. Instantiation runs the memory allocation and initialization code.
When a request arrives, the runtime invokes the fetch export. It passes the request data as arguments. Your function runs. It processes the data and returns a response. The runtime takes the response and sends it back to the client.
The async model is cooperative. WASM is single-threaded. Your function runs on one thread. If you block, you block the entire edge node. Rust's async yields to the event loop. You must .await on I/O operations. The worker crate provides async methods for fetching data. If you call a blocking function, the runtime will panic or hang. Don't block the event loop. If it doesn't have .await, it probably shouldn't run.
Realistic edge function
A real function parses JSON, calls an external API, and returns a result. The worker crate provides methods for JSON parsing and fetching. You use serde for serialization. You must enable the derive feature for serde.
// src/lib.rs
use worker::*;
use serde::{Deserialize, Serialize};
/// Input structure for the request body.
#[derive(Deserialize)]
struct Input {
name: String,
}
/// Output structure for the response body.
#[derive(Serialize)]
struct Output {
message: String,
}
/// Entry point for the worker.
/// Parses JSON, fetches data, and returns a response.
#[event(fetch)]
async fn main(req: Request, env: Env, ctx: Context) -> Result<Response> {
// Parse the request body as JSON
let input = req.json::<Input>().await?;
// Fetch data from an external API
let url = env.url("API_URL")?;
let resp = req.to_request_builder(url.as_str())
.method(Method::Get)
.send()
.await?;
// Return a response with the fetched data
let output = Output {
message: format!("Hello, {}! API responded.", input.name),
};
Response::from_json(&output)
}
The req.json() method parses the body as JSON. It returns a Result. The ? operator propagates errors. The env.url() method retrieves a secret or variable from the environment. The req.to_request_builder() method creates a request builder. You chain methods to configure the request. The send() method executes the request. It returns a Result<Response>.
The worker crate uses wasm-bindgen under the hood. wasm-bindgen generates the glue code to call JavaScript functions from Rust. The provider's runtime exposes JavaScript APIs. wasm-bindgen bridges Rust types to JavaScript types. You don't need to write wasm-bindgen macros manually when using worker. The worker crate handles the interop.
Convention aside: In WASM, Rc is preferred over Arc. WASM is single-threaded. Arc provides atomic reference counting, which is unnecessary overhead. Rc is faster and smaller. The community calls this the "single-threaded optimization." Use Rc unless you have a specific reason for Arc.
Pitfalls and compiler fights
You will hit walls. The compiler will reject code that works on the server. The errors are specific to the WASM target.
If you import std::net::TcpStream, the compiler fails with E0433 (can't find crate) or a linking error. The WASM target does not implement std::net. You must use the provider's fetch API. The error message might say "unresolved import" or "linking failed". The root cause is the missing implementation.
If you use tokio without the WASM features, the compiler fails with trait bound errors. tokio requires a runtime. The WASM target needs a specific runtime configuration. You must enable the rt and macros features for tokio. Even then, tokio might not work with all providers. The worker crate provides its own async runtime. Stick to the provider's async primitives.
If you forget crate-type = ["cdylib"], the build produces a static library. The provider cannot load it. The upload might succeed, but the runtime will fail to find the exports. You get a runtime error like "export not found". Always check the Cargo.toml configuration.
If you use println!, the output goes nowhere. WASM has no stdout. The worker crate provides console_log for logging. Use console_log to send logs to the provider's dashboard. The convention is to use console_error_panic_hook for debugging. It prints Rust panic messages to the console. Add it to your initialization code. It turns cryptic crashes into readable errors.
// src/lib.rs
use worker::*;
#[event(fetch)]
async fn main(req: Request, env: Env, ctx: Context) -> Result<Response> {
// Install panic hook for debugging
console_error_panic_hook::install_once();
console_log!("Request received");
Response::ok("OK")
}
The console_error_panic_hook::install_once() function installs the hook. It runs only once. It catches panics and prints the message. This is essential for debugging. Without it, panics result in silent failures or generic errors. Trust the sandbox. The restrictions keep your code portable and safe.
Decision matrix
Use Rust WASM when cold start latency is the bottleneck and you need sub-millisecond initialization. Use Rust WASM when you are processing heavy payloads at the edge and need Rust's performance without the overhead of a virtual machine. Use Rust WASM when you want to share logic between a browser frontend and a serverless backend via a common WASM module. Reach for Python when you need rapid iteration and the ecosystem libraries are sufficient for your task. Reach for Go when you require full OS access and can tolerate larger binary sizes and slower cold starts.