Announcing Spin v4.1

By The Spin Project
August 26, 2026

The CNCF Spin project just released Spin v4.1.0.

Back in Spin 4.0 we shipped a production-grade implementation of WASI Preview 3 built on the March 2026 release candidate, and committed to supporting it long-term. That commitment just got a lot easier to make: WASIp3 is final, and Spin 4.1 runs on it.

On top of that foundation, 4.1 adds composable HTTP middleware, written as ordinary Wasm components and built on the async streaming WASIp3 gives us. There’s also async MySQL, service chaining for WASIp3 components, and a set of connection limits and telemetry improvements for folks running Spin in production.

WASI Preview 3 is final

WASI 0.3.0 is done. After the March 2026 release candidate that shipped in Spin 4.0, WASIp3’s interfaces are finalized, and Spin 4.1, running on Wasmtime 48, speaks it natively.

If you’re already on Spin 4.0, the good news is that this is a non-event for your applications. Spin 4.1 supports both the final 0.3.0 interfaces and the 0.3.0-rc-2026-03-15 snapshot, so components you built against 4.0 keep running exactly as they did. You can see both in the spin:up@4.1.0 world:

package spin:up@4.1.0;

world http-trigger {
  include platform;
  export wasi:http/handler@0.3.0;
}

world platform {
  include wasi:cli/imports@0.2.6;
  include wasi:cli/imports@0.3.0-rc-2026-03-15;
  include wasi:cli/imports@0.3.0;
  import wasi:http/outgoing-handler@0.2.6;
  import wasi:http/client@0.3.0-rc-2026-03-15;
  import wasi:http/client@0.3.0;
  // ...
}

And as always, WASIp2 components continue to run unchanged: wasi:cli/imports@0.2.6 and wasi:http/outgoing-handler@0.2.6 are right there in the platform world. There’s even a new http-rust-p2 template if you have a reason to stay on WASIp2 for a while.

A big thank you to everyone in the Bytecode Alliance who carried WASIp3 across the finish line. It’s been a long road from “experimental, opt-in, might break between releases” in Spin 3.5 to a finished standard, and Spin is much better for it.

HTTP middleware

Here’s the headline feature, and it’s one we’ve wanted for a long time.

Every web framework you’ve ever used has a middleware story: a way to slot auth, logging, compression, header rewriting, or rate limiting in front of your handler without tangling it up in your business logic. Spin 4.1 brings that same idea to Wasm components, as an ordinary, sandboxed component instead of a proxy bolted on in front of your app.

You declare a middleware chain right on your HTTP trigger:

[[trigger.http]]
route = "/..."
component = "api-server"
dependencies.middleware = [
    { component = "auth", inherit_configuration = ["allowed_outbound_hosts"] },
    { component = "cors" }
]

Requests flow outermost to innermost: auth, then cors, then your api-server component. Responses flow back out the other way. It’s the same middleware pipeline pattern used by frameworks like Express or ASP.NET, except every layer here is an independently compiled, independently sandboxed Wasm component that you can build, test, version, and publish on its own.

The contract is just wasi:http/handler

There’s no special Spin middleware API to learn. A middleware component imports and exports the same interface your application component exports:

interface handler {
  handle: async func(request: request) -> result<response, error-code>;
}

Spin wires the chain together automatically because every link speaks the same interface. A middleware is just a handler that happens to call the next one, which means middleware and application components are the same kind of thing: an ordinary component you can publish to a registry and drop into anyone’s chain.

Writing one: touching the request

Here’s a middleware that fetches a random animal fact and attaches it as a request header, from the new examples/http-middleware sample:

#[http_service]
async fn add_animal_fact_header(
    request: Request,
) -> Result<Response, spin_sdk::wasip3::http::types::ErrorCode> {
    let animal_fact_response =
        spin_sdk::http::get("https://random-data-api.fermyon.app/animals/json").await?;
    let animal_fact_json = animal_fact_response.into_body().bytes().await?;
    let animal_fact: AnimalFact = serde_json::from_slice(&animal_fact_json).unwrap();

    let (mut parts, body) = request.into_parts();
    parts.headers.append(
        "animal-fact",
        HeaderValue::from_str(&animal_fact.fact).unwrap(),
    );
    let request = Request::from_parts(parts, body);

    spin_sdk::http::next(request).await
}

spin_sdk::http::next(request) is your “call the next handler” primitive. The interesting part is that calling it is optional. If you don’t call it, you’ve short-circuited the chain: nothing behind you ever sees the request, and your response goes straight back to the client. That’s all an auth middleware is:

#[http_service]
async fn require_token(request: Request) -> anyhow::Result<impl IntoResponse> {
    let authorized = request
        .headers()
        .get("authorization")
        .is_some_and(is_valid_token);

    if !authorized {
        return Ok(Response::new(401, "unauthorized"));
    }

    Ok(spin_sdk::http::next(request).await?)
}

Writing one: transforming the response, without buffering

This is the part where WASIp3 really earns its keep: because bodies are stream<u8> and handlers are async func, a streaming transform is just a loop. Under WASIp2, doing the same thing meant buffering the whole body in memory or hand-rolling a state machine to pump it through.

#[http_service]
async fn yell_it(request: Request) -> anyhow::Result<impl IntoResponse> {
    let response = spin_sdk::http::next(request).await?;

    let (parts, body) = response.into_parts();
    let mut body_stm = body.stream();

    let (mut tx, transformed_body) = spin_sdk::http::body::stream();

    spin_sdk::wasip3::spawn(async move {
        while let Some(chunk) = body_stm.next().await {
            let Ok(chunk) = chunk else { break; };
            let text = String::from_utf8_lossy(chunk.as_ref());
            let bytes = bytes::Bytes::from_owner(text.to_uppercase().into_bytes());
            if tx.send(bytes).await.is_err() { break; }
        }
    });

    Ok(Response::from_parts(parts, transformed_body))
}

The response headers go back to the client immediately, and the body is transformed chunk by chunk on a spawned task, using the same spin_sdk::wasip3::spawn pattern we used for streaming SQLite rows in the 4.0 post. No buffering: the same middleware works just as well on a 4 KB JSON payload as on a 4 GB stream.

Middleware doesn’t get a free pass on capabilities

Suppose the api-server component is allowed to call your token endpoint:

[component.api-server]
allowed_outbound_hosts = ["https://tokens.example.com"]

The auth middleware in front of it can reach that endpoint only because api-server grants it that capability, and only because the trigger says inherit_configuration = ["allowed_outbound_hosts"]. Middleware gets no ambient authority.

This is exactly the fine-grained capability inheritance that landed in Spin 4.0 for component dependencies, now applied to middleware. If you want a middleware to reach a capability, list it explicitly in inherit_configuration; otherwise leave it out and the middleware gets nothing.

Hosts have to opt in

When you deploy an application that uses middleware, it matters whether the host you deploy it to actually knows how to run middleware, and a host built on an older version of Spin might not. Spin’s distribution format makes sure of this: a host that can’t run middleware rejects the application up front with a clear error, rather than quietly running it without the security layer you carefully placed in front.

Async MySQL, with streaming rows

Spin 4.0 asyncified key-value, SQLite, PostgreSQL, Redis, and outbound HTTP. MySQL was the one that didn’t make it. In 4.1, it does:

resource connection {
  open: static async func(address: string) -> result<connection, error>;

  query: async func(statement: string, params: list<parameter-value>)
    -> result<tuple<list<column>, stream<row>, future<result<_, error>>>, error>;

  execute: async func(statement: string, params: list<parameter-value>) -> result<_, error>;
}

query hands you column metadata right away, plus a stream<row> and a trailing future for any error hit partway through. You can start processing rows before MySQL has finished producing them, and a big result set no longer has to fit in guest memory all at once. It’s the same streaming story PostgreSQL got in 4.0.

Service chaining across WASIp2 and WASIp3

Local service chaining, where one component calls another over http://other-component.spin.internal without ever leaving the host, now works for WASIp3 components, and it works across the P3/P2 boundary. A WASIp3 component can chain to a WASIp2 component and vice versa, so you can migrate a multi-component application one piece at a time instead of all at once.

Target environments

Spin 4.0 introduced spin new -E <environment> for targeting a specific deployment platform. Spin 4.1 builds on that.

There’s a new command for seeing and refreshing what’s available:

$ spin targets list      # list known target environments
$ spin targets update    # refresh the list from the master catalogue

You can set a default so you don’t have to type -E forever:

$ export SPIN_NEW_DEFAULT_ENVIRONMENT=<environment>

Environments can also constrain more than WIT worlds. An environment can declare which key-value stores, SQLite databases, or AI models it actually provides, and spin build will tell you before you deploy:

Component api-server can’t run in environment spin-up because it requires the my-database key-value store which the environment does not support

A heads-up on WAGI

Spin 4.1 begins printing a deprecation warning for WAGI components, and the WAGI examples have been removed from the repository. Your WAGI components will keep running: this is the start of a gradual, managed deprecation, not a break. WAGI is a pre-component-model compatibility shim and we’d like to retire it, so if you’re still running WAGI components, now’s a good time to plan a move to a proper Wasm component.

Running Spin in production

The rest of this post is for the folks running Spin rather than writing for it.

Connection limits and backpressure

Spin has always been careful about what an application is allowed to talk to. It’s been less opinionated about how much. A single app could exhaust a host’s resources through outbound HTTP, PostgreSQL, MySQL, Redis, MQTT, or raw sockets, each with its own independent notion of “unlimited.”

Spin 4.1 adds connection limits across all of those, configured in your runtime config:

[outbound_networking]
max_total_connections = 500    # global ceiling across every outbound kind
max_socket_connections = 100   # raw wasi-sockets specifically

[outbound_http]
max_connections = 200

[outbound_pg]
max_connections = 50

[outbound_redis]
max_connections = 50

If you’re using [outbound_http] max_concurrent_requests, it still works but is now deprecated in favor of max_connections. See the upgrade notes below for one subtlety to watch for when you migrate.

Sharper telemetry

Spin’s metrics now go straight to the OpenTelemetry SDK instead of being routed through tracing events, so they’re cheaper to emit and behave the way your OTel tooling expects. Spin has also switched to opentelemetry-semantic-conventions for attribute names instead of hand-rolled strings, so Spin’s telemetry now lines up with the rest of your observability stack out of the box.

If you’ve built dashboards or alerts against Spin’s OpenTelemetry attribute names, see the upgrade notes below.

Upgrading to Spin 4.1

  1. Install Spin 4.1 from spinframework.dev/install or grab a binary from the release page.
  2. Update your templates:
    spin templates install --git https://github.com/spinframework/spin --update
    
  3. That’s it for app developers. Applications built for Spin 4.0, including WASIp3 components built against the March release candidate, run on 4.1 unchanged.

And if you want to try middleware, the fastest path is the example in the repo:

$ git clone https://github.com/spinframework/spin
$ cd spin/examples/http-middleware
$ spin build --up

If you’re operating Spin, two things are worth a look:

  • In your runtime config file, replace [outbound_http] max_concurrent_requests with max_connections, remembering that 0 now means no connections rather than one.
  • Re-check any dashboards or alerts built on Spin’s OpenTelemetry attribute names, which now follow the OTel semantic conventions.

Thank you

Spin 4.1 is the work of contributors across a lot of organizations: to Spin itself, to wasmtime, to wit-bindgen, to the SDKs, and to the WASIp3 standardization effort in the Bytecode Alliance that reached 0.3.0 in this cycle. Thank you all, and thank you to the CNCF for continuing to support the project.

A special welcome to @TheRayquaza and @carsonfarmer, who both made their first contributions in this release, and congratulations to @alexcrichton on joining as a Spin maintainer.

Stay in touch

Join us at weekly project meetings, say hi on the Spin CNCF Slack channel, and follow @spinframework on X.

Ready to build? Head to the Spin quickstart, or browse the Spin Hub for inspiration.