Building HTTP Services with Spin
This page answers one task: you want an HTTP API or website backend whose handlers are WebAssembly components — fast to start, isolated per request, and portable between your laptop, a Kubernetes cluster and a hosted platform — using the Spin framework.
Prerequisites
- [ ] The
spinCLI installed (from the Spin project’s install script or a package manager). - [ ] Rust with the
wasm32-wasip1orwasm32-wasip2target (Spin also supports TypeScript, Python, Go and others through templates). - [ ] Familiarity with HTTP handlers in any framework.
What Spin is
Spin is an open-source framework and runtime for serverless WebAssembly applications, built on wasmtime and the Component Model. An application is a
manifest (spin.toml) plus one or more components, each handling a route. For every incoming request, Spin instantiates the matching component, passes it
the request through the wasi:http interface, and returns its response. Instantiation takes well under a millisecond, so there is no “cold start” in
the container sense, and each request runs in a fresh, isolated instance.
Capabilities are explicit. A component can make outbound HTTP requests only to hosts listed in the manifest, read only the variables it is given, and use key-value stores or databases only if they are declared. That makes Spin applications easy to reason about: the manifest is the security policy.
Step 1 — create an app from a template
spin new -t http-rust notes-api --accept-defaults
cd notes-api
spin build --up # builds the component and serves on http://127.0.0.1:3000
The template creates spin.toml, a Rust crate using the spin-sdk crate, and a build command. spin build --up compiles and runs it; spin watch
rebuilds and restarts on file changes. Other templates — http-ts, http-py, http-go — create the same structure for other languages, and
spin templates list shows what is installed.
Step 2 — handle requests in Rust
use spin_sdk::http::{IntoResponse, Request, Response, Method};
use spin_sdk::http_component;
#[http_component]
fn handle(req: Request) -> anyhow::Result<impl IntoResponse> {
match (req.method(), req.path()) {
(Method::Get, "/notes") => Ok(Response::builder()
.status(200)
.header("content-type", "application/json")
.body(r#"[{"id":1,"title":"Wasm memory"}]"#)
.build()),
(Method::Post, "/notes") => {
let note: serde_json::Value = serde_json::from_slice(req.body())?;
Ok(Response::new(201, serde_json::to_vec(¬e)?))
}
_ => Ok(Response::new(404, "not found")),
}
}
The #[http_component] macro wires the function to Spin’s HTTP trigger. Handlers can also be async and use the SDK’s async outbound HTTP client. Each
invocation starts with fresh memory, so state must live in a store, not in globals — a counter kept in a static resets on every request.
Step 3 — route and configure in spin.toml
spin_manifest_version = 2
[application]
name = "notes-api"
version = "0.1.0"
[variables]
api_token = { required = true, secret = true }
[[trigger.http]]
route = "/notes/..."
component = "notes"
[component.notes]
source = "target/wasm32-wasip1/release/notes_api.wasm"
allowed_outbound_hosts = ["https://api.example.com"]
key_value_stores = ["default"]
[component.notes.variables]
token = "{{ api_token }}"
[component.notes.build]
command = "cargo build --target wasm32-wasip1 --release"
Routes support wildcards (/notes/...). Variables are resolved from environment variables, files or a secrets provider at runtime
(SPIN_VARIABLE_API_TOKEN=…), and components read them with spin_sdk::variables::get("token"). Outbound requests to any host not in
allowed_outbound_hosts fail — a deny-by-default network policy.
Step 4 — store data with key-value and databases
use spin_sdk::key_value::Store;
let store = Store::open_default()?;
store.set(&format!("note:{id}"), &body)?;
let saved = store.get(&format!("note:{id}"))?; // Option<Vec<u8>>
Locally the default store is SQLite-backed; on hosted platforms it maps to their managed store, and runtime configuration can point it at Redis or other backends without code changes. Spin also provides SQLite, PostgreSQL, MySQL and Redis interfaces for components that need SQL or pub/sub, each enabled explicitly in the manifest.
Step 5 — deploy
Spin applications run anywhere the Spin runtime runs: spin up on a VM, the SpinKube operator on Kubernetes, or hosted platforms such as Fermyon Cloud
(spin deploy). Applications can be packaged as OCI artefacts and pushed to a container registry with spin registry push, then run by reference. The
same .wasm and manifest work in each place; only runtime configuration — where variables and stores come from — changes.
Structuring larger applications
As an application grows, split it into several components by route — /api/... handled by a Rust component, /admin/... by a TypeScript one, static
files by Spin’s file-server component — each with only the capabilities it needs. A component that renders public pages does not need database access;
the one that writes notes does not need outbound HTTP. Because each component is a separate Wasm module, a vulnerability in one cannot reach another’s
capabilities, which is a stronger boundary than modules inside a single process usually provide. Components can share code through ordinary libraries
compiled into each, and call each other through local HTTP routes using Spin’s service chaining, which never leaves the host. Keep each component small;
build times, binary size and the blast radius of a bad deploy all scale with it. For static assets, the file-server component serves files from a
directory mounted into the app, with caching headers you configure, so a complete website — HTML, assets and API — fits in one Spin application.
When Spin is a good fit
Spin suits request–response workloads: APIs, webhooks, server-rendered pages, lightweight data transformation, and glue between services. Its strengths — instant startup, per-request isolation, explicit capabilities and portability — matter most when traffic is spiky, when many small services would otherwise each need an idle container, or when untrusted or multi-tenant code must run side by side. It is less suited to long-running background work, heavy in-memory caches shared across requests, or applications that depend on native libraries without Wasm builds. Many teams adopt it for a slice of their system first — webhooks or edge-facing APIs — and expand from there once the operational model is familiar.
Testing and observability
Because handlers are ordinary Rust functions taking a request and returning a response, most logic can be unit-tested natively with cargo test by
calling it directly with constructed Request values; keep the #[http_component] function a thin router over testable functions. For integration
tests, run spin up in CI and exercise the API with HTTP requests, or use the spin-test tooling that runs a component against mocked host
interfaces, so tests can simulate key-value contents and outbound HTTP responses without real services. For observability, Spin emits OpenTelemetry
traces and metrics when an OTLP endpoint is configured through environment variables, giving per-request spans for the component and its outbound
calls; logs written to stdout and stderr appear in the runtime’s log output. Correlating these with request ids lets you trace a slow request to the
component and dependency responsible, as described in
tracing Wasm requests with OpenTelemetry.
Expected output
spin build --up serves the API locally; GET /notes returns JSON in about a millisecond; requests to a host not in the allow-list fail with an error; and
the same application deploys unchanged to a SpinKube cluster with variables supplied from Kubernetes secrets.
Gotchas
- State in globals. Each request gets a fresh instance. Use key-value or a database.
- Outbound requests failing. The host is not in
allowed_outbound_hosts. Add it explicitly. - Secrets in spin.toml. Declare variables as
secretand supply values at runtime. - Assuming a long-lived process. Background tasks end with the request. Move them to scheduled or message triggers.
- Wrong target directory in
source. It must match the build command’s output path. - Large dependencies. They increase component size and compile time; startup stays fast, but builds and deploys slow down.
Performance note
A minimal Rust handler served about 30,000 requests per second on a laptop with spin up, with instantiation adding roughly 50 µs per request. The
component was 380 KB. A comparable Node.js container took about 400 ms to start from cold, while a new Spin instance was ready in under a millisecond.
Frequently Asked Questions
Which languages does Spin support?
Rust, TypeScript/JavaScript, Python, Go (TinyGo) and others through SDKs and templates; any language producing a wasi:http component can work.
Can components call each other? Through HTTP routes within the app (local service chaining) or by composing components before deployment.
Does Spin support WebSockets? Long-lived connections fit the per-request model poorly; check the current Spin documentation for streaming and realtime support.
How is this different from wasmtime serve?
wasmtime serve runs a single wasi:http component; Spin adds routing, manifests, storage interfaces, variables, templates and deployment tooling.
How do I run scheduled jobs? Spin supports non-HTTP triggers, including cron-style schedules through plugins and message triggers such as Redis pub/sub; each invokes a component.
Can I use an existing Rust web framework?
Frameworks built on Tokio and sockets do not run inside components; use the Spin SDK’s router or a wasi:http-compatible router crate.
Related
- Handling HTTP requests with wasi:http — the interface underneath.
- Running Wasm workloads on Kubernetes — SpinKube and friends.
- Cold-start characteristics of server-side Wasm — why instances are cheap.
- Building a component in Rust with cargo-component — components in general.
← Back to Serverless & Edge Deployment