Handling HTTP Requests with wasi:http
This page answers one task: you want an HTTP handler compiled to a WebAssembly component that depends only on the standard wasi:http interface — not on a
particular framework — so it runs unchanged in wasmtime, Spin, and any other host that implements WASI HTTP.
Prerequisites
- [ ] Rust with the
wasm32-wasip2target (rustup target add wasm32-wasip2). - [ ] wasmtime 20 or newer for
wasmtime serve. - [ ] Familiarity with the Component Model, as in building a component in Rust with cargo-component.
The interface
WASI preview 2 defines HTTP as a set of WIT interfaces in the wasi:http package. A server-side handler implements one export,
wasi:http/incoming-handler, whose single function handle receives an incoming-request and a response-outparam. The request is a resource: the
handler queries its method, path, headers and body through methods. To respond, the handler builds an outgoing-response, sets it on the out-parameter,
and writes the body through an output stream. For outbound calls, the wasi:http/outgoing-handler import sends outgoing-requests and returns
future-incoming-responses.
Because bodies are streams and responses are set through an out-parameter, handlers can stream large responses, start sending before the body is complete, and process request bodies incrementally. The interface is deliberately low level; frameworks and helper crates build friendlier APIs on top, while the binary contract stays the same everywhere.
Step 1 — create the project
The wasi crate provides generated bindings for the WASI interfaces, including a helper macro for exporting an incoming handler:
[package]
name = "hello-http"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[dependencies]
wasi = "0.14"
Build with cargo build --target wasm32-wasip2 --release. The wasm32-wasip2 target produces a component directly; no adapter step is needed, and the output
can be inspected with wasm-tools component wit to confirm the export.
Step 2 — implement the handler
use wasi::http::types::{Fields, IncomingRequest, OutgoingBody, OutgoingResponse, ResponseOutparam, Method};
wasi::http::proxy::export!(Component);
struct Component;
impl wasi::exports::http::incoming_handler::Guest for Component {
fn handle(request: IncomingRequest, out: ResponseOutparam) {
let path = request.path_with_query().unwrap_or_default();
let (status, body) = match (request.method(), path.as_str()) {
(Method::Get, "/") => (200, "hello from wasi:http\n".to_string()),
(Method::Get, p) if p.starts_with("/echo/") => (200, format!("{}\n", &p[6..])),
_ => (404, "not found\n".to_string()),
};
let headers = Fields::from_list(&[("content-type".into(), b"text/plain".to_vec())]).unwrap();
let response = OutgoingResponse::new(headers);
response.set_status_code(status).unwrap();
let out_body = response.body().unwrap();
ResponseOutparam::set(out, Ok(response));
let stream = out_body.write().unwrap();
stream.blocking_write_and_flush(body.as_bytes()).unwrap();
drop(stream);
OutgoingBody::finish(out_body, None).unwrap();
}
}
Ordering matters: obtain the body before setting the response, write through the stream, drop the stream, then finish the body (optionally with
trailers). Forgetting finish leaves the client waiting for the end of the response until the host times the request out.
Step 3 — serve it locally
cargo build --target wasm32-wasip2 --release
wasmtime serve -S cli=y target/wasm32-wasip2/release/hello_http.wasm
curl http://127.0.0.1:8080/echo/wasm
wasmtime serve listens on port 8080 by default, instantiates the component for each request and calls handle. The -S cli=y flag grants the CLI
interfaces (environment variables, stdout and stderr for logging); without it, those imports are not available. Each request gets a fresh instance, so
globals do not persist between requests. The wider runtime is described in
running components in wasmtime.
Step 4 — read request bodies and call out
Request bodies are streams. Consume them with request.consume() and read until the stream closes:
let body = request.consume().unwrap();
let stream = body.stream().unwrap();
let mut data = Vec::new();
loop {
match stream.blocking_read(64 * 1024) {
Ok(chunk) => data.extend(chunk),
Err(_) => break, // closed
}
}
Enforce a maximum body size while reading, rather than after, to bound memory. For outbound requests, build an OutgoingRequest and call
wasi::http::outgoing_handler::handle, then wait on the returned future; hosts decide which destinations are allowed, which is how platforms such as Spin
enforce their outbound allow-lists.
Step 5 — use a higher-level crate for real services
The raw interface is verbose for application code. Crates such as waki and wstd, and framework SDKs such as Spin’s, wrap it in familiar request and
response types, routers and async/await, while still producing a standard wasi:http component. Writing directly against the interface is still worth
doing once: it shows exactly what the host provides and keeps you independent of any one framework.
Streaming responses
Because the response body is an output stream, a handler can send data as it produces it rather than buffering a whole response in memory. That matters
for large exports, server-sent events, and proxies that forward an upstream body. Set the response with its headers first — status and content type
cannot change once the body starts flowing — then write chunks to the stream as they are ready, flushing as appropriate, and finish the body when done.
Writes may block when the client reads slowly; blocking_write_and_flush handles back-pressure by waiting, and the non-blocking check_write and
subscribe pollables let more advanced handlers interleave several streams. For proxies, read the upstream body in chunks from the outgoing request’s
response and write each chunk to the downstream stream immediately, so memory stays at one chunk regardless of payload size. Trailers — headers sent
after the body, used by gRPC and some streaming protocols — are passed to finish. With streaming, a 1 GB download costs the component the same few
kilobytes of memory as a tiny response.
Logging and errors
A handler that panics traps, and the host returns a generic error response — usually a 500 — to the client. Catch expected failures yourself and return meaningful status codes: 400 for malformed input, 413 for oversized bodies, 502 when an upstream call fails. Write logs to stderr (with the CLI interfaces granted) in a structured format such as JSON lines, including a request identifier taken from an incoming header or generated per request, so log aggregators can correlate entries. Hosts typically capture component stderr and attach their own metadata, as described in emitting structured logs from server-side Wasm.
Portability across hosts
The value of targeting wasi:http rather than a framework-specific API is that the same component can move between hosts. In practice, portability is
excellent for the core interface — requests, responses, streams — and narrower for everything around it: configuration, key-value storage, databases and
messaging are not yet standardised everywhere, and hosts offer their own interfaces for them. Keep the HTTP handling and business logic in code that
depends only on wasi:http and plain Rust, and isolate host-specific capabilities behind small traits or modules, so moving between wasmtime, Spin and
edge platforms means swapping an adapter rather than rewriting handlers. Pin the WASI interface version your component targets — WIT packages are
versioned, such as wasi:http@0.2.x — and check each host’s supported versions, since hosts gain support for new minor versions at different times.
Testing the same component on two hosts in CI is the most reliable way to keep it portable.
Expected output
curl http://127.0.0.1:8080/echo/wasm prints wasm; an unknown path returns 404; wasm-tools component wit on the binary shows an export of
wasi:http/incoming-handler; and the same .wasm runs under a second wasi:http host without recompilation.
Gotchas
- Never calling
OutgoingBody::finish. The response never completes. Finish every body. - Setting the response after writing. Obtain the body, set the response, then write.
- No
-S cli=yfor logging. Without the CLI interfaces, stdout and environment imports fail. Grant them. - Unbounded body reads. Large uploads exhaust memory. Enforce a limit while reading.
- Panicking on bad input. The client gets an opaque 500. Return explicit 4xx and 5xx responses.
- Changing headers after streaming starts. They are already sent. Set status and headers before writing the body.
- Expecting globals to persist. Hosts usually instantiate per request. Use external storage.
Performance note
The handler component was about 90 KB after wasm-opt. Under wasmtime serve on a laptop, it sustained about 25,000 requests per second for small
responses, with instantiation and the canonical ABI adding roughly 30–60 µs per request.
Frequently Asked Questions
Is wasi:http stable? It is part of WASI 0.2, which is stable; minor versions add features compatibly.
Can I use async Rust?
Yes, with crates such as wstd that implement an executor over WASI pollables; native async in the Component Model is evolving.
Does it run in the browser?
jco can provide a wasi:http implementation for Node and browsers, mainly for outbound requests and testing.
How do I add TLS? The host terminates TLS; the component sees plain HTTP requests.
Can one component serve several routes?
Yes — route inside handle on the path, or let the host route to different components, as Spin does.
How large can a request body be? Hosts set their own limits; the component should enforce its own maximum while reading, so a misconfigured host cannot exhaust its memory.
Related
- Building HTTP services with Spin — a framework on top of the interface.
- WASI preview1 vs preview2 differences — where wasi:http fits.
- Making outbound HTTP requests from WASI — the client side.
- Using WIT resources and handles — requests and responses as resources.
← Back to Serverless & Edge Deployment