The Wasm Component Model on Fastly Compute

Caching: Host Cache vs. Guest Cache

Every send call so far in this series has gone through a cache, automatically, without a line of code from you making that happen. compute.wit also gives you a second way to drive that same cache—yourself, by hand. It's tempting to treat these as two settings on one dial, more automatic versus less. They're a lot further apart than that. They're two different pipelines that happen to write into the same storage underneath, and which one you're in changes what's actually possible.

Host cache: the fixed pipeline

This is the one you've already been using, throughout this series, without writing a line of code for it. It's the closest thing to a fixed-function graphics pipeline that Compute has: the platform runs the whole fetch-and-cache sequence internally, and guest code gets a small, closed set of knobs rather than a stage it can step into.

Flow diagram of host cache mode. Guest code builds a request, optionally calls set-cache-override to set ttl, stale-while-revalidate, pci and surrogate-key, and then makes a single send call. Everything after that happens inside the Fastly host with no seam guest code can reach: the host looks up the request in the cache using a key it picks itself, fetches the backend on a miss, and stores the response according to that response's cache headers. On a hit it returns the stored object directly. Either way, guest code gets back only the finished response. one call, and the host runs every step of it your code set-cache-override ttl · swr · pci · surrogate-key send(request, body, backend) inside the host, with no seam for your code look up in cache the host picks the key fetch the backend only on a miss store the response per its cache headers miss hit response-with-body back to your code your code never holds the bytes between the fetch and the write

Those knobs live in cache-override, passed to request.set-cache-override:

WIT
    /// Sets the cache override behavior for this request.
    ///
    /// This setting will override any cache directive headers returned in response to this request.
    set-cache-override: func(
      cache-override: cache-override,
    ) -> result<_, error>;
WIT
  /// Optional override for response caching behavior.
  variant cache-override {
    /// Do not override the behavior specified in the origin response’s cache control headers.
    none,

    /// Do not cache the response to this request, regardless of the origin response’s headers.
    pass,

    /// Override particular cache control settings.
    override(cache-override-details)
  }

  /// The fields for the `override` arm of `cache-override`.
  ///
  /// The origin response’s cache control headers will be used for ttl and
  /// `stale-while-revalidate` if `none`.
  record cache-override-details {
    ttl: option<u32>,
    stale-while-revalidate: option<u32>,
    pci: bool,
    surrogate-key: option<string>,

    /// Additional options may be added in the future via this resource type.
    extra: option<borrow<extra-cache-override-details>>,
  }

Four fields, and that's genuinely the whole surface: ttl, stale-while-revalidate, a pci compliance flag, and surrogate-key—plus pass, which drops out of caching entirely. Notice what's missing: nothing here lets you change the cache key itself. Host cache decides what identifies an object; guest code only gets to influence how long it lives and whether it's tagged for purging.

There is a way to override the cache key on the host path, and it's worth naming precisely because of how it's exposed rather than in spite of it: the Rust SDK's experimental module (its doc comment: "Experimental Compute features. These features are not yet stable and may change in backwards-incompatible ways without major-version bumps") sets a hex-encoded value into a fastly-xqd-cache-key request header rather than calling any dedicated ABI function. There's no cache-key field on cache-override-details for it to occupy. That's the fixed-pipeline analogy holding up under its one apparent exception, not breaking down: even the one host-cache lever compute.wit doesn't give you a stable field for has to be smuggled in as an undocumented header, not added as a first-class option.

Guest cache: you run the pipeline yourself

Now the same picture again, with one thing changed. The steps are the steps either way: look in the cache, fetch what isn't there, store what came back. What moves is the border they sit inside.

Flow diagram of guest cache mode, showing the same three steps as host cache mode but running inside guest code instead. Your code calls transaction-lookup, which reaches the host cache and collapses concurrent lookups on the same key. On a hit the entry is simply read back. Otherwise the entry comes back carrying must-insert-or-update, and the rest happens in your code: ask for a suggested backend request with get-suggested-backend-request, fetch it yourself with send-uncached rather than send, change the response however you like, and only then call prepare-response-for-storage to ask what should happen to it. The recommended storage action decides which write goes back to the host cache. the same three steps, except now they are yours your code transaction-lookup(request) the host cache collapses concurrent lookups on the key found: just read it entry: must-insert-or-update inside your code, and every gap is reachable ask for a suggested request get-suggested-backend-request fetch it yourself send-uncached, not send change it however you like the window host cache never opens ask what to do with it prepare-response-for-storage transaction-insert the host cache storage-action decides which write the fetch is yours, and so is everything between it and the write

Guest cache means driving http-cache's own entry resource directly, starting with the function that actually opens a transaction:

WIT
    /// Performs a cache lookup based on the given request.
    ///
    /// This operation always participates in request collapsing and may return an obligation to
    /// insert or update responses, and/or stale responses.
    ///
    /// The request is not consumed.
    transaction-lookup: static func(
      req-handle: borrow<request>,
      options: lookup-options,
    ) -> result<entry, error>;

transaction-lookup doesn't just hand back a cached value or nothing. Its doc comment says the call "may return an obligation to insert or update responses, and/or stale responses." That's the same election Core Cache's transactions ran: if nothing fresh is cached, the entry handle you get back comes with a real claim attached, and having done the lookup, the code that has to settle it is yours.

Settling it means fetching the backend response yourself, through send-uncached rather than send. Triggering automatic caching a second time on top of a transaction you're already managing by hand would defeat the entire point. Then you hand what came back to the rest of this interface and let it decide what happens to it. The map post introduced that interface as the one that "should look very familiar to users of the Core Cache API." The difference that actually matters is where the backend fetch happens.

WIT
    /// Prepares a suggested request to make to a backend to satisfy the looked-up request.
    ///
    /// If there is a stored, stale response, this suggested request may be for revalidation. If the
    /// looked-up request is ranged, the suggested request will be unranged in order to try caching
    /// the entire response.
    get-suggested-backend-request: func() -> result<request, error>;
WIT
    /// Adjusts a response into the appropriate form for storage and provides a storage action
    /// recommendation.
    ///
    /// For example, if the looked-up request contains conditional headers, this function will
    /// interpret a `304 Not Modified` response for revalidation by updating headers.
    ///
    /// In addition to the updated response, this function returns the recommended storage action.
    prepare-response-for-storage: func(
      response: borrow<response>,
    ) -> result<tuple<storage-action, response>, error>;
WIT
  /// The suggested action to take for spec-recommended behavior following
  /// `prepare-response-for-storage`.
  enum storage-action {
    /// Insert the response into cache (for `transaction-insert` and
    /// `transaction-insert-and-stream-back`).
    insert,
    /// Update the stale response in cache (for `transaction-update` and
    /// `transaction-update-and-return-fresh`).
    update,
    /// Do not store this response.
    do-not-store,
    /// Do not store this response, and furthermore record its non-cacheability for other pending
    /// requests (`transaction-record-not-cacheable`).
    record-uncacheable,
  }

Those four transaction-* names are the writes themselves, and they come later. What matters here is that the decision between them is handed to guest code rather than made for it.

get-suggested-backend-request hands back a suggestion, the same word http-cache's top-level doc comment used for is-request-cacheable in the map post. Nothing requires sending that exact request, or even sending it to the same backend. And prepare-response-for-storage takes whatever response you hand it, with no requirement that it's untouched from whatever came back over the wire. Host cache never gives guest code that window: send fetches and caches in one host-internal step, so there's no point where your code holds the bytes before they're written. Guest cache's whole shape is built around exactly that window existing—fetch it yourself, do whatever you want to the response in between, and only then call prepare-response-for-storage to find out whether (and how) it should be written.

Beyond the WIT

A binding sitting on top of this has to decide how visible the host/guest split should be, and the three SDKs have already converged on more of an answer than you'd expect. All of them try guest caching first and quietly fall back to host caching in the cases it can't cover: a Pass override, a PURGE request, an image-optimizer request, a host build without the needed exports. None of them ask the caller to choose. Go gates the whole guest path behind a build tag on top of that, so an app compiled without fsthttp_guest_cache gets host caching and nothing else, but that's one decision made at build time by whoever compiles the app, not a fork a caller picks between per request.

Where they agree more interestingly is on where the silence has to stop. Each one asks the same question under nearly the same name (must_use_guest_caching in Rust and the JS runtime, mustUseGuestCaching in Go), and the question is always this: did the caller attach a before-send or after-send hook? If they did, falling back stops being a downgrade the caller can afford not to hear about, and the SDK raises an error rather than quietly serving them the other pipeline.

That's the part worth copying. Auto-negotiation is right for the common case, because most callers genuinely don't care which pipeline answered them. It stops being right the moment a caller has asked for something only one pipeline can do, and guest cache's payload-transformation window is exactly that kind of request. So the rule that falls out isn't "expose the split" or "hide the split." It's narrower and more useful than either: hide it until the caller reaches for a capability that lives on one side only, and then fail loudly instead of handing them the other side and saying nothing.

Next up: the RFC 9111 rules guest cache's convenience functions are quietly built around, and the interception surface for participating in what send already does instead of just letting it happen.