The Wasm Component Model on Fastly Compute
Fanout: Letting Your Origin Decide What a Connection Becomes
September 17, 2026, by Kats Omuro (@katsuyukiomuro (opens in a new window) on X/Twitter)
Before Fastly, I worked at Fanout, Inc. We were the company that originally built Pushpin, an open-source proxy; GRIP, the protocol Pushpin speaks to the servers behind it; and Fanout Cloud, the hosted, SaaS version of Pushpin. Fastly acquired Fanout in March 2022, and that's how I ended up at Fastly.
So this post is about something with a longer history than its footprint in compute.wit. That footprint is one function, with no doc comment.
Three names for related things
It helps to separate them before looking at any code.
Pushpin (opens in a new window) is a reverse proxy. It sits in front of your server and holds client connections open: HTTP streams, long polls, WebSockets. It keeps holding them after your server has finished answering, and pushes data down them whenever someone publishes to a channel they're subscribed to. It's open source, and Fastly's documentation now describes it as maintained by Fastly.
GRIP is how Pushpin and your server communicate. Your server answers an ordinary HTTP request with ordinary HTTP, plus a few instructions in the response, such as "hold this connection open as a stream" and "subscribe it to the channel test." Pushpin follows the instructions, and your server never has to hold the connection itself.
Fanout (opens in a new window) is the Fastly product. It's GRIP proxying built into Fastly's network, and Fastly's guide describes it as "built on the open GRIP protocol and powered by the open-source Pushpin project." A Compute program uses it by handing a request over, much like WebSocket passthrough did.
The whole ABI surface
resource request {
...
redirect-to-grip-proxy: func(
backend: borrow<backend>,
) -> result<_, error>;
}
That's it. A method on request, a borrowed backend, and result<_, error>. It's the same shape as redirect-to-websocket-proxy, a few lines above it in the same resource. That one has a doc comment and this one doesn't.
Nothing in compute.wit connects this function to the word Fanout. The only clue is the abbreviation in its name. For what it does, you have to look outside the WIT, and the Rust SDK's Request::handoff_fanout has the missing comment in fastly 0.13.1 (opens in a new window):
/// Pass the request through the Fanout GRIP proxy and on to a backend.
///
/// This can only be used on services that have the Fanout feature enabled.
///
/// The sending completes in the background. Once this method has been called, no other
/// response can be sent to this request, and the application can exit without affecting the
/// send.
pub fn handoff_fanout(self, backend: &str) -> Result<(), SendError> {
Its last paragraph is word for word the one on WebSocket passthrough. Once the handoff succeeds, the response isn't yours to send, and your program can exit.
The difference is in the first line. Passthrough gives the connection to the backend. Fanout sends the request through the GRIP proxy to the backend, and the backend's response goes to the proxy. The proxy decides what the client sees, based on what that response tells it.
A handler and an origin
Full working code: full example on GitHub (opens in a new window).
The Compute side is about as small as a handler gets:
mod bindings;
use bindings::{
exports::fastly::compute::http_incoming,
fastly::compute::{backend, http_body, http_resp},
};
fn respond(status: u16, text: String) -> Result<(), ()> {
let response = http_resp::Response::new().map_err(|_| ())?;
response.set_status(status).map_err(|_| ())?;
response.insert_header("content-type", b"text/plain").map_err(|_| ())?;
let body = http_body::new().map_err(|_| ())?;
http_body::write(&body, text.as_bytes()).map_err(|_| ())?;
http_resp::send_downstream(response, body).map_err(|_| ())
}
struct Fanout;
impl http_incoming::Guest for Fanout {
fn handle(request: http_incoming::Request, _request_body: http_body::Body) -> Result<(), ()> {
let origin = backend::Backend::open("origin").map_err(|_| ())?;
match request.redirect_to_grip_proxy(&origin) {
// Fanout holds the connection now, and asks the origin what to do with it.
Ok(()) => Ok(()),
Err(e) => respond(501, format!("redirect-to-grip-proxy: Err({e:?})\n")),
}
}
}
bindings::export!(Fanout with_types_in bindings);
The interesting part is the origin, because that's the side speaking GRIP. The example includes a stand-in written for Node.js, origin.mjs:
// A stand-in origin that answers in GRIP. It tells the proxy to hold every
// request open as an HTTP stream, subscribed to the channel "test".
import http from "node:http";
http
.createServer((req, res) => {
const signed = req.headers["grip-sig"] ? "present" : "absent";
console.log(`${req.method} ${req.url} (Grip-Sig ${signed})`);
res.writeHead(200, {
"Content-Type": "text/plain",
"Grip-Hold": "stream",
"Grip-Channel": "test",
});
res.end("[subscribed to channel test]\n");
})
.listen(3000, "127.0.0.1", () => console.log("GRIP origin on http://127.0.0.1:3000"));
It answers every request immediately and closes its response. Two of the headers are GRIP. Grip-Hold: stream tells the proxy to keep the client's connection open as an HTTP stream after this response ends, and Grip-Channel: test subscribes that connection to a channel named test. The body is the first thing the client receives.
That's as much GRIP as this post needs. The protocol also covers long polling, WebSockets carried over HTTP, and verifying that a request really came through the proxy. Pushpin's GRIP guide (opens in a new window) is the place to learn it properly.
Configuring it, in production and locally
In production, Fanout is part of the service, and [setup] asks for it:
[setup]
[setup.backends]
# Replace with an origin of your own that answers in GRIP.
[setup.backends.origin]
address = "example.com"
description = "An origin that answers in GRIP"
port = 443
[setup.products]
[setup.products.fanout]
enable = true
[setup.products.fanout] asks the CLI to enable Fanout on the service it creates on the first fastly compute publish. Fanout is a paid add-on, disabled by default, so the account needs access to it first. The guide notes that enabling or disabling it on an existing service affects every version at once, including the active one. The origin address here is a placeholder, because GRIP needs an origin that answers in GRIP, and origin.mjs on your laptop isn't one Fastly can reach.
For local testing there's no Fastly network, so the Fastly CLI runs Pushpin next to Viceroy. It's the real Pushpin, so you need Pushpin installed. [local_server.pushpin] turns that on:
[local_server]
[local_server.backends]
# origin.mjs, the stand-in GRIP origin in this directory.
[local_server.backends.origin]
url = "http://127.0.0.1:3000"
# Runs Pushpin alongside Viceroy. Pushpin must be installed, and on the PATH
# unless pushpin_path says where.
[local_server.pushpin]
enable = true
When fastly compute serve starts, it configures Pushpin from the backends you've defined and starts it before Viceroy. Pushpin's proxy listens on port 7677 by default and its publishing endpoint on 5561. You can also point at a Pushpin binary outside your PATH with pushpin_path, or pass --pushpin-path on the command line.
Start the origin with node origin.mjs, then fastly compute serve, and open a connection:
curl -i -N http://127.0.0.1:7676/
HTTP/1.1 200 OK
content-type: text/plain
date: Sun, 13 Sep 2026 14:30:42 GMT
connection: Transfer-Encoding
transfer-encoding: chunked
[subscribed to channel test]
curl doesn't return. The origin's response ended, but the client's didn't. The Grip-Hold and Grip-Channel headers are gone too, because they were addressed to the proxy, which consumed them.
Now, from a second terminal, publish to the channel:
curl -d '{"items":[{"channel":"test","formats":{"http-stream":{"content":"hello from a publisher\n"}}}]}' \
http://127.0.0.1:5561/publish/
Published
Publish once more with different content, and both messages arrive on the waiting connection:
[subscribed to channel test]
hello from a publisher
and another
The origin's console tells the rest:
GRIP origin on http://127.0.0.1:3000
GET / (Grip-Sig absent)
GET / (Grip-Sig present)
The first request is Viceroy checking that the origin backend is up before it starts serving. The second came through Pushpin, which is why it carries a Grip-Sig header: a signature the proxy adds so an origin can tell a proxied request from a direct one. Locally, the CLI configures Pushpin to sign with a fixed development key, viceroy_dev_signing_key_dont_use_in_production, which means what it says. An origin that verifies Grip-Sig shouldn't accept that key anywhere but on your laptop.
On a real service, publishing works the same way, but you send it to Fastly's API instead of a local port: a POST to https://api.fastly.com/service/{SERVICE_ID}/publish/, with an API token in the Fastly-Key header. Channel names are scoped to the service.
Without Pushpin
If [local_server.pushpin] isn't enabled, there's no proxy to hand anything to, and Viceroy answers the way a service without Fanout would:
HTTP/1.1 501 Not Implemented
content-type: text/plain
content-length: 48
date: Sun, 13 Sep 2026 14:31:17 GMT
redirect-to-grip-proxy: Err(Error::Unsupported)
error.unsupported, and since the handoff failed, the program still owns the response and sends its own.
One more constraint comes from the service side. A service with Fanout enabled converts every WebSocket upgrade it receives into WebSocket-over-HTTP, the GRIP form of a WebSocket. So a single service can use Fanout or raw WebSocket passthrough, but not both.
Beyond the WIT
redirect-to-grip-proxy and redirect-to-websocket-proxy have identical signatures. Both are methods on request, both borrow a backend, and both return result<_, error>. If you only had the types, you'd reasonably conclude they were the same operation pointed at different products.
They aren't the same operation, and the backend is where they differ. With passthrough, the backend receives a connection. It has to accept the upgrade, hold the socket, and stay up for as long as the client does. With Fanout, the backend answers a question. It receives one ordinary HTTP request and sends one ordinary HTTP response, and it's done. Whatever happens to the connection afterward is the proxy's job. That's why a GRIP origin can be a serverless function, or a server behind a load balancer that has no idea which client is which, or, as Fastly's starter kit does, the same Compute service called a second time.
So the thing you're naming with that backend parameter isn't the same kind of thing in the two functions, and nothing in the WIT says so. A backend that works perfectly for one is broken for the other. An echo server handed a GRIP request answers with no GRIP instructions, so the proxy simply relays its response and the client gets a plain response. A GRIP origin handed a raw WebSocket gets an upgrade it was never written to accept.
A binding can keep those apart even though the ABI doesn't. Separate names are the minimum, and both the Rust and Go SDKs already call them handoff_websocket and handoff_fanout rather than exposing one function with a mode. A binding could go further and type the backends themselves, so a backend meant for GRIP can't be passed to passthrough, though whether that's worth the ceremony depends on how many backends a typical service has. Either way, the lesson generalizes past these two functions. When two WIT functions share a signature, check what each one expects of the resources you pass it before you write the binding as if they were interchangeable.
Next: edge rate limiting, where a counter you can't see into decides whether a request goes through.