The Wasm Component Model on Fastly Compute

Dynamic Backends by Hand

EDIT (2026-08-18): added a comparison against the real Rust SDK's BackendBuilder, and fixed a pair of quote characters in a WIT doc-comment quotation.

Last time, backend.open(name) looked up a backend you'd already declared ahead of time—in your Fastly service config in production (or fastly.toml locally, for testing against the same setup).

But that's not the only option: maybe the target comes from a config lookup, a request header, a routing decision made at runtime. compute.wit has an answer for that too.

Building a backend without declaring one first

Dynamic backends live on the same backend interface as everything from last time, but register-dynamic-backend isn't a method on the backend resource—it's a free function:

WIT
/// Creates a new dynamic backend.
///
/// The arguments are the name of the new backend to use, along with a string describing the
/// backend host. The latter can be of the form:
///
/// - "<ip address>"
/// - "<hostname>"
/// - "<ip address>:<port>"
/// - "<hostname>:<port>"
///
/// The name can be whatever you would like, as long as it does not match the name of any of the
/// static service backends nor match any other dynamic backends built during this service
/// instance. (Names can overlap between different instances of the same service—they will be
/// treated as completely separate entities and will not be pooled—but you cannot, for example,
/// declare a dynamic backend named “dynamic-backend” twice in the same sandbox.)
///
/// ...
register-dynamic-backend: func(
  prefix: string,
  target: string,
  options: dynamic-backend-options,
) -> result<backend, error>;

That's the whole shape: a name you choose (prefix), a host to reach (target, as loose or specific as you need), and an options bundle—and back comes an ordinary backend, the exact same kind of handle backend.open hands you.

Nothing downstream—http-req::send, the read loop, any of it—needs to know whether the backend in front of it came from a fastly.toml entry or a runtime call. is-dynamic, a plain instance method on backend, is the only way to tell the two apart after the fact:

WIT
/// Returns `true` if the backend is a “dynamic” backend.
is-dynamic: func() -> result<bool, error>;

Configuring it: dynamic-backend-options

A static backend gets its settings from the Fastly UI (or fastly.toml locally): address, port, timeouts, TLS, connection pooling, and so on. A dynamic one needs the same information from somewhere, and that somewhere is dynamic-backend-options, a builder-shaped resource:

WIT
resource dynamic-backend-options {
  /// Constructs an options resource with default values for all other possible fields for the
  /// backend, which can be overridden using the other methods provided.
  constructor();

  /// Sets a host header override when contacting this backend.
  ///
  /// ...
  override-host: func(value: string);

  /// Sets the connection timeout, in milliseconds, for this backend.
  ///
  /// Defaults to 1,000ms (1s).
  connect-timeout: func(value: u32);

  /// Sets a timeout, in milliseconds, that applies between the time of connection and the time we
  /// get the first byte back.
  ///
  /// Defaults to 15,000ms (15s).
  first-byte-timeout: func(value: u32);

  /// Sets a timeout, in milliseconds, that applies between any two bytes we receive across the
  /// wire.
  ///
  /// Defaults to 10,000ms (10s).
  between-bytes-timeout: func(value: u32);

  /// Enables or disables TLS to connect to the backend.
  ///
  /// ...
  use-tls: func(value: bool);

  /// Sets the minimum TLS version for connecting to the backend.
  ///
  /// Setting this will enable TLS for the connection as a side effect.
  tls-min-version: func(value: tls-version);

  /// Sets the maximum TLS version for connecting to the backend.
  ///
  /// Setting this will enable TLS for the connection as a side effect. (
  tls-max-version: func(value: tls-version);

  /// Defines the hostname that the server certificate should declare, and turn on validation
  /// during backend connections.
  ///
  /// ...
  cert-hostname: func(value: string);

  ...
}

This is a builder, not a record you fill in all at once. constructor() gives you a dynamic-backend-options handle with every field defaulted, and each setter mutates one field at a time.

It's worth noticing that these setters don't return anything: no result<_, error>, nothing to unwrap. Setting an override host or a timeout can't fail; there's nothing to reject yet, since it's just staging a value until register-dynamic-backend actually acts on the whole bundle.

Put Fastly's Rust SDK next to that and one difference jumps out immediately. Backend::builder(name, target) (opens in a new window) hands back a BackendBuilder whose setters take self and return Self, so they chain: Backend::builder("target", host).enable_ssl().finish(). The WIT setters mutate a handle in place and return nothing, which means they can't chain at all. The SDK gets its fluent shape by owning a plain guest-side value it can move through the chain, and a host-owned resource is exactly the thing that can't be moved that way.

Where failure lives doesn't change, though. finish() returns Result<Backend, BackendCreationError>, the same place register-dynamic-backend returns result<backend, error>, and nothing before it can fail. So the eager-versus-deferred split those WIT signatures showed you is a real property of the feature rather than an artifact of how the ABI happens to spell it.

The same-name rule

The doc comment for register-dynamic-backend says a dynamic backend's name can be anything "as long as it does not match... any other dynamic backend built during this service instance"—read that as a collision rule, not just a warning, because Fastly's own backends guide (opens in a new window) spells out what actually happens on a name collision:

  • Same name, and every other property (target, timeouts, TLS settings, everything) identical → the registration succeeds, silently reusing what's already there.
  • Same name, but any property differs → the registration fails.

In other words, calling register-dynamic-backend again with a name you've already used isn't automatically an error—it's only an error if you've changed your mind about what that name means. A common pattern the guide suggests: derive the name itself from a hash of the target and its settings, so identical configs always converge on the same name and changed ones never collide with the old one by accident.

One honest caveat: testing this locally against Viceroy, re-registering the exact same name with genuinely identical settings still came back as an error (Error::GenericError), not the silent reuse the production docs describe. This isn't just something I hit—it's a filed, known Viceroy issue (fastly/Viceroy#363 (opens in a new window)), which describes the exact same mismatch: Viceroy rejects a same-name/same-settings backend that production accepts. Treat the same-name rule above as what real Fastly Compute does, and don't lean on identical-settings reuse actually working locally until that issue's closed.

Putting it together

Rust

mod bindings;

use bindings::{
    exports::fastly::compute::http_incoming,
    fastly::compute::{backend, http_body, http_req, http_resp},
};


fn get_header_value(
    request: &http_incoming::Request,
    name: &str,
) -> Result<Option<String>, http_req::Error> {
    let mut max_len: u64 = 128;
    loop {
        match request.get_header_value(name, max_len) {
            Ok(Some(bytes)) => return Ok(Some(String::from_utf8_lossy(&bytes).into_owned())),
            Ok(None) => return Ok(None),
            Err(http_req::Error::BufferLen(needed)) => max_len = needed,
            Err(e) => return Err(e),
        }
    }
}

struct DynamicBackends;

impl http_incoming::Guest for DynamicBackends {
    fn handle(request: http_incoming::Request, _request_body: http_body::Body) -> Result<(), ()> {
        let target_host = get_header_value(&request, "x-target-host")
            .map_err(|_| ())?
            .unwrap_or_else(|| "http-me.fastly.dev".to_string());

        // Setters return nothing: there's nothing to validate yet, only
        // values staged until the registration below acts on them.
        let options = backend::DynamicBackendOptions::new();
        options.use_tls(true);

        // A free function on the backend interface, not a static method on
        // the resource the way backend.open is.
        let backend =
            backend::register_dynamic_backend("target", &target_host, options).map_err(|_| ())?;
        let is_dynamic = backend.is_dynamic().map_err(|_| ())?;


        let out_request = http_req::Request::new().map_err(|_| ())?;
        out_request.set_method("GET").map_err(|_| ())?;
        out_request.set_uri("/anything").map_err(|_| ())?;

        let out_body = http_body::new().map_err(|_| ())?;

        let (_backend_response, backend_body) =
            http_req::send(out_request, out_body, &backend).map_err(|_| ())?;


        let mut buf = Vec::new();
        loop {
            let chunk = http_body::read(&backend_body, 8192).map_err(|_| ())?;
            if chunk.is_empty() {
                break;
            }
            buf.extend_from_slice(&chunk);
        }
        let backend_text = String::from_utf8_lossy(&buf).into_owned();


        let response = http_resp::Response::new().map_err(|_| ())?;
        response
            .insert_header("content-type", b"text/plain")
            .map_err(|_| ())?;

        let body = http_body::new().map_err(|_| ())?;
        let msg = format!(
            "Registered backend for {target_host} (is_dynamic: {is_dynamic})\n\
             Backend said:\n{backend_text}\n"
        );
        http_body::write(&body, msg.as_bytes()).map_err(|_| ())?;

        http_resp::send_downstream(response, body).map_err(|_| ())?;

        Ok(())
    }
}

bindings::export!(DynamicBackends with_types_in bindings);

Nothing after register_dynamic_backend returns is any different from last post's code—same send, same read loop, same send_downstream. The only new work is choosing the target and building the options; from there, a dynamic backend behaves exactly like a static one.

Running it

Full working code: full example on GitHub (opens in a new window). No fastly.toml backend declaration needed this time—that's the whole point:

Bash
fastly compute serve
Bash
curl http://127.0.0.1:7676/ (opens in a new window)
Terminal output
Registered backend for http-me.fastly.dev (is_dynamic: true)
Backend said:
{
  "args": "",
  "body": "",
  "headers": {
    "host": "http-me.fastly.dev"
  },
  "method": "GET",
  "origin": "unknown",
  "url": "/anything"
}

Or point it somewhere else entirely, with no code change and no config change:

Bash
curl -H "x-target-host: example.com" http://127.0.0.1:7676/ (opens in a new window)
Terminal output
Registered backend for example.com (is_dynamic: true)
Backend said:
<!doctype html><html lang="en"><head><title>Example Domain</title><link rel="icon" href="data:,"><meta name="viewport" content="width=device-width, initial-scale=1"><style>body{background:#eee;width:60vw;margin:15vh auto;font-family:system-ui,sans-serif}h1{font-size:1.5em}div{opacity:0.8}a:link,a:visited{color:#348}</style></head><body><div><h1>Example Domain</h1><p>This domain is for use in documentation examples without needing permission. Avoid use in operations.</p><p><a href="https://iana.org/domains/example">Learn (opens in a new window) more</a></p></div></body></html>

A completely different backend, completely different response—the host came from the request this time, not from anything declared ahead of time.

Rust
let target_host = get_header_value(&request, "x-target-host")
    .map_err(|_| ())?
    .unwrap_or_else(|| "http-me.fastly.dev".to_string());

The code above (left unfixed only for demo purposes) is an SSRF vulnerability (opens in a new window): x-target-host goes straight from request to register-dynamic-backend with nothing checking it in between. That's fine for a demo aimed at the public internet, but ship this pattern as-is and you've built an attacker a way to make your service originate requests wherever they choose—an internal admin endpoint, a cloud metadata address, anywhere your Compute service can reach that they can't. Never trust request-derived input for a backend target without validating it against a set of hosts you've decided are safe to reach.

Beyond the WIT

A careful reading of register-dynamic-backend's doc comment reveals a piece of history: there was a time when dynamic backends had to be enabled for your service to use the feature.

WIT
/// Dynamic backends must be enabled for the Compute service. You can determine whether or not
/// dynamic backends have been allowed for the current service by checking for the
/// `error.unsupported` error result. This error only arises when attempting to use dynamic
/// backends with a service that has not had dynamic backends enabled, or dynamic backends have
/// been administratively prohibited for the node in response to an ongoing incident.

Dynamic backends are enabled today for all services by default. However, Fastly can still shut dynamic backends off administratively in response to an ongoing incident, and the way you'd find out is error.unsupported, the same generic value Compute uses everywhere else something can't be done. If you deploy this and get back a bare "unsupported" with no other context, that's worth checking.

Next: every backend call so far has been one request, sent, then waited on, start to finish, before moving on. That's about to change—send-async lets you have more than one request in flight at once, and pollable/select (which we already met in disguise, back when body turned out to be one) is how you find out which one finished first.