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

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.

One request through a wasi:http handler The host receives an HTTP request and calls the component's handle function with an incoming-request resource and a response-outparam. The handler reads the method, path and body, builds an outgoing-response, sets it on the out-parameter, writes the body to the output stream and finishes it. host (wasmtime, Spin) component handle(incoming-request, response-outparam) request.method(), path-with-query(), consume() response-outparam.set(outgoing-response) body.write(bytes); finish()

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.

From Rust source to a served endpoint The Rust crate implements the incoming-handler export using the wasi crate's bindings, compiles for wasm32-wasip2 into a component, and wasmtime serve instantiates it per request on port 8080. The same component can be deployed to other wasi:http hosts. Rust handler impl incoming_ handler::Guest cargo build --target wasm32-wasip2 component .wasm exports wasi:http/incoming- handler wasmtime serve instance per request other hosts Spin, edge platforms

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=y for 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.

Per-request overhead under wasmtime serve Microseconds per request spent instantiating the component, in the handler itself for a small response, and in the host's HTTP handling, measured on a laptop. microseconds per request instantiate component 35 µs handler + canonical ABI 12 µs host HTTP handling 20 µs

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.

← Back to Serverless & Edge Deployment