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 spin CLI installed (from the Spin project’s install script or a package manager).
  • [ ] Rust with the wasm32-wasip1 or wasm32-wasip2 target (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.

Anatomy of a Spin application The spin.toml manifest maps routes to components and declares their capabilities. Each component is a Wasm module handling wasi:http requests. Spin's runtime, built on wasmtime, instantiates a fresh component instance per request and provides only the declared capabilities. spin.toml routes, variables, allowed hosts, stores components one .wasm per route group Spin SDK Request, Response, kv, variables Spin runtime (wasmtime) instance per request host platform laptop, Kubernetes, Fermyon Cloud

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(&note)?))
        }
        _ => 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.

A request through a Spin application An HTTP request arrives at the Spin runtime, which matches the route in spin.toml, instantiates the component with only its declared capabilities, calls the handler, and returns the response. The instance is discarded afterwards; state lives in a key-value store. HTTP request GET /notes/42 route match spin.toml triggers fresh instance declared capabilities only handler runs kv, variables, outbound HTTP response instance discarded

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 secret and 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.

Time from first request to handler running Milliseconds from the arrival of a request to an idle service until handler code starts, for a Spin component instantiated per request and a Node.js container started from cold. ms to first handler execution Spin component instance 0.1 ms Node.js container cold start 400 ms

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.

← Back to Serverless & Edge Deployment