The Wasm Component Model on Fastly Compute

WebSocket Passthrough: Handing the Connection Away

With HTTP, the client makes a request and the server answers with a response. A WebSocket (opens in a new window) is a connection between a client and a server that is bidirectional and stays open, so that once it's established, either side can send the other arbitrary data at any time.

It's worth noting that the "socket" part of WebSocket is a metaphor and is not a real socket. WebSockets can't be used in place of real sockets, and they can only be used for client-to-server connections, not peer-to-peer.

The natural guess may be that a WebSocket at the edge means your program reads frames, writes frames, and stays running for as long as the connection lasts. But a Compute program is only invoked for a request and ends when the request ends. A WebSocket can stay open for hours and cannot be accommodated under this model.

So Compute doesn't give you WebSockets. Instead, it gives you a method to get out of the way and let the Fastly platform hold the WebSocket connection.

The interface

WebSocket passthrough (opens in a new window) lives in http-req, as a method on the request resource:

WIT
resource request {
  ...

  /// Passes the WebSocket directly to a backend.
  ///
  /// This can only be used on services that have the WebSockets feature enabled and on requests
  /// that are valid WebSocket requests.
  ///
  /// 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.
  ///
  /// See the [WebSockets passthrough] documentation for a high-level description of this feature.
  ///
  /// [WebSockets passthrough]: https://www.fastly.com/documentation/guides/concepts/real-time-messaging/websockets-tunnel/ (opens in a new window)
  redirect-to-websocket-proxy: func(
    backend: borrow<backend>,
  ) -> result<_, error>;

  ...
}

A WebSocket connection starts life as an ordinary HTTP GET with an Upgrade: websocket header. That's the request your program receives. redirect-to-websocket-proxy hands that request to a backend, and from then on the Fastly platform (no longer your application) holds the client connection and relays traffic in both directions. The doc comment says what that costs you: no other response can be sent, and your program may exit.

Sequence diagram of WebSocket passthrough with four lanes: client, Fastly POP, your program, and backend. The client sends a GET request with Upgrade: websocket to the Fastly POP, which gives it to your program. Your program calls redirect-to-websocket-proxy and exits without sending a response. The Fastly POP sends the same request to the backend, and the backend's 101 Switching Protocols goes back to the client through the POP. From then on the WebSocket is open, and messages travel between the client and the backend in both directions through the POP, with no program running, until either side disconnects. Client Fastly POP Your program Backend GET, Upgrade: websocket the upgrade request redirect-to-websocket-proxy exits, no response the same request 101 Switching Protocols WebSocket open: Fastly relays both ways, no program running hello hello and so on, until the client or the backend disconnects

While we're here, have a look at what it doesn't take. There's no body parameter, which fits, since an upgrade request doesn't have one. And the request itself isn't passed by value. It's self, borrowed, so the handle is still yours after the call returns.

Handing it off

Full working code: full example on GitHub (opens in a new window).

In production, the backend is a normal static backend with nothing WebSocket-specific about it. What the service does need is the WebSockets product, and [setup] can ask for that too:

TOML
[setup]

  [setup.backends]

    [setup.backends.echo]
      address = "echo.websocket.org"
      description = "A public WebSocket echo server"
      port = 443

  [setup.products]

    [setup.products.websockets]
      enable = true

The CLI enables the products listed there the first time it deploys the package and creates the service. WebSockets is a paid add-on, though, so an account without access to it can't turn it on this way either. On an existing service it's a per-service switch in the control panel or the Products API, and Fastly's guide warns that flipping it takes effect immediately, on every version of the service including the active one. A service without it can't hand anything off, and redirect-to-websocket-proxy fails with error.unsupported.

For local testing, the same backend goes under [local_server]:

TOML
[local_server]

  [local_server.backends]

    [local_server.backends.echo]
      url = "https://echo.websocket.org"

The URL can be written with https:// or wss:// (the scheme for WebSockets over TLS). Viceroy reaches the echo server either way.

That's all Viceroy needs. It treats passthrough as enabled unless you tell it otherwise, which this post will do shortly.

Here's the handler:

Rust

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 WebsocketPassthrough;

impl http_incoming::Guest for WebsocketPassthrough {
    fn handle(request: http_incoming::Request, _request_body: http_body::Body) -> Result<(), ()> {
        let echo = backend::Backend::open("echo").map_err(|_| ())?;

        let upgrade = request.get_header_value("upgrade", 64).ok().flatten();
        let is_websocket = upgrade.is_some_and(|v| v.eq_ignore_ascii_case(b"websocket"));

        if is_websocket {
            match request.redirect_to_websocket_proxy(&echo) {
                // Fastly holds the connection from here. Return without responding.
                Ok(()) => return Ok(()),
                Err(e) => {
                    return respond(501, format!("redirect-to-websocket-proxy: Err({e:?})\n"));
                }
            }
        }

        respond(200, "Not a WebSocket request. Send one with Upgrade: websocket.\n".to_string())
    }
}

bindings::export!(WebsocketPassthrough with_types_in bindings);

The success arm is the unusual part. Every handler earlier in this series has ended by sending a response. This one returns Ok(()) having sent nothing, and that's correct. The response isn't the program's to send anymore.

Only WebSocket requests get handed off. Fastly's guide recommends checking for the Upgrade header first if a service handles anything else, and a request without it gets an ordinary response here.

Watching a message go through

Start the server with fastly compute serve. You can see the handoff happen with nothing more than curl, by sending the upgrade headers yourself:

Bash
curl -i -N -H "Connection: Upgrade" -H "Upgrade: websocket" \
  -H "Sec-WebSocket-Version: 13" -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
  http://127.0.0.1:7676/
Terminal output
HTTP/1.1 101 Switching Protocols
upgrade: websocket
connection: Upgrade
sec-websocket-accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
date: Sun, 13 Sep 2026 14:23:35 GMT
server: Fly/728d0e526 (2026-09-10)
via: 1.1 fly.io, 1.1 fly.io
fly-request-id: 01M2DJDW25PP18DS10S6Z69V4H-nrt

None of those headers came from the program. The 101 and the server and via lines belong to the echo server. The program's part was over before the backend answered.

curl stops at the handshake. To send messages, you need a WebSocket client, and wscat (opens in a new window) from npm is an easy one to reach for. npx runs it without a global install:

Bash
npx wscat -c ws://127.0.0.1:7676/

Once it connects, type a message and press Enter. Here's a session with two messages:

Terminal output
Connected (press CTRL+C to quit)
< Request served by 4d896d95b55478
> hello through Fastly
< hello through Fastly
> second message
< second message

Lines starting with > are what you typed, and lines starting with < came back. The first < line arrived before anything was typed. The echo server sends it to every new connection, so it's the backend talking to your client directly. After that, each message went from wscat to Viceroy, through to the echo server, and back the same way.

Viceroy's log shows one guest run for the whole session, the one that handed off the upgrade request. No Compute code ran for any of the messages.

The product that isn't enabled

A real service without the WebSockets product refuses the handoff. Viceroy can simulate that, with a flag you pass through the CLI:

Bash
fastly compute serve --viceroy-args="--enable-local-websocket-passthrough=false"

Send the same upgrade request again:

Terminal output
HTTP/1.1 501 Not Implemented
content-type: text/plain
content-length: 53
date: Sun, 13 Sep 2026 14:24:07 GMT

redirect-to-websocket-proxy: Err(Error::Unsupported)

wscat gets the same answer and gives up:

Terminal output
error: Unexpected server response: 501

redirect-to-websocket-proxy fails here with error.unsupported, and because the call failed, the program still owns the response and can send its own. That's the Err arm doing its job. A failed handoff isn't a dead request.

What passthrough won't do

The handoff is total, and the guide lists what that rules out.

It's one connection to origin per client. Every browser tab that opens a WebSocket through Fastly becomes a WebSocket from Fastly to your backend, and your backend holds all of them. For one-to-many publishing, where Fastly holds the connections and your backend doesn't, Fastly offers Fanout instead.

It doesn't combine with shielding or the Next-Gen WAF (opens in a new window). The connection goes to the backend you named, from the POP that received it.

Once handed off, a connection isn't subject to the backend's between-bytes timeout. It stays open until the client or the server disconnects, and Fastly relays the disconnect to the other side.

Headers survive the handoff. If your program adds, removes, or changes a header on the request before calling redirect-to-websocket-proxy, the backend sees the changed request. That's what the borrowed self makes possible: the request is still yours to edit right up to the call.

Fanout and passthrough don't share a service, either. With Fanout enabled, a service converts every WebSocket upgrade into WebSocket-over-HTTP (opens in a new window), a protocol that lets a conventional HTTP server talk to a WebSocket client, so raw passthrough stops being available on that service.

Beyond the WIT

redirect-to-websocket-proxy returns result<_, error>, the most ordinary return type in compute.wit. What it means is not ordinary. On success, the request is finished as far as your program is concerned. On failure, nothing happened, and your program is still responsible for answering.

The type can't tell those apart. In both cases you still hold the request handle, because it was only borrowed. But on the success path that handle refers to a request whose response now belongs to somebody else. Nothing in the ABI stops you from reading it, or from trying to send a response afterward. That's a mistake the host has to catch for you, when it happens.

A binding can do better, and the Rust SDK shows one way. Its Request::handoff_websocket takes self, so a successful call consumes the request and there's nothing left to misuse. On failure it returns a SendError, and before making the call it clones the request, without its body, so that error can carry it back to you. Success takes the request away, and failure hands it back for the fallback response.

That's the shape to aim for whenever a WIT function borrows a handle but ends its useful life on success. The borrow exists so that failure is recoverable. The binding's job is to make success final, in whatever way the language allows. Rust's move semantics do that directly, since a moved value can't be named again. A language without them, whether that's C++ with its still-usable moved-from objects or a garbage-collected language where a reference can't be made to disappear, has to settle for marking the object spent and throwing on any later use. The call site ends up with two outcomes, and a program that ignores the distinction fails loudly.

Next: the other half of real-time messaging. Fanout holds the connections for you, speaks a protocol called GRIP to your backend, and has a longer history than its place in compute.wit suggests.