The Wasm Component Model on Fastly Compute

Two Pollables, One World

A Compute component imports two resources called pollable. One is WASI's, from wasi:io/poll. The other is Fastly's, in async-io, and it's the one you've been using every time you waited on a body or a backend. As far as the type system is concerned, they have nothing to do with each other.

Last time read the world's import list. This time, two entries on it turn out to answer the same question in two incompatible ways.

Two definitions

Two Requests at Once, by Hand quoted async-io's pollable: is-ready to check without blocking, new-ready to make one that's already ready, and two free functions, select and select-with-timeout, that block on a list.

Here it is again, for reference:

WIT
interface async-io {
  /// An object supporting generic async operations.
  /// ...
  resource pollable {
    /// ...
    is-ready: func() -> bool;

    /// ...
    new-ready: static func() -> pollable;
  }

  /// ...
  select: func(handles: list<borrow<pollable>>) -> u32;

  /// ...
  select-with-timeout: func(handles: list<borrow<pollable>>, timeout-ms: u32) -> option<u32>;
}

And here's WASI's, from wasi:io 0.2.6:

WIT
interface poll {
  /// `pollable` represents a single I/O event which may be ready, or not.
  @since(version = 0.2.0)
  resource pollable {
    /// Return the readiness of a pollable. This function never blocks.
    ///
    /// Returns `true` when the pollable is ready, and `false` otherwise.
    @since(version = 0.2.0)
    ready: func() -> bool;
    /// `block` returns immediately if the pollable is ready, and otherwise
    /// blocks until ready.
    ///
    /// This function is equivalent to calling `poll.poll` on a list
    /// containing only this pollable.
    @since(version = 0.2.0)
    block: func();
  }

  /// Poll for completion on a set of pollables.
  ///
  /// This function takes a list of pollables, which identify I/O sources of
  /// interest, and waits until one or more of the events is ready for I/O.
  ///
  /// The result `list<u32>` contains one or more indices of handles in the
  /// argument list that is ready for I/O.
  ///
  /// This function traps if either:
  /// - the list is empty, or:
  /// - the list contains more elements than can be indexed with a `u32` value.
  ///
  /// A timeout can be implemented by adding a pollable from the
  /// wasi-clocks API to the list.
  ///
  /// This function does not return a `result`; polling in itself does not
  /// do any I/O so it doesn't fail. If any of the I/O sources identified by
  /// the pollables has an error, it is indicated by marking the source as
  /// being ready for I/O.
  @since(version = 0.2.0)
  poll: func(in: list<borrow<pollable>>) -> list<u32>;
}

The two are close cousins. Each has a nonblocking readiness check and a function that blocks on a list and answers with indices. The differences are in the details:

  1. poll returns every ready index, as a list<u32>. select and select-with-timeout return only the first.
  2. poll has no timeout. Its doc comment says to add a clock's pollable to the list instead. async-io builds the timeout into select-with-timeout, in milliseconds.
  3. A WASI pollable is something you subscribe to. A monotonic clock hands you one with subscribe-duration, and a stream hands you one with subscribe, separate from the stream itself. In compute.wit, the thing you're waiting on is the pollable: use async-io.{pollable as body}, as The Body Isn't Part of the Request found, and the same alias for other pending handles, including pending-response, pending-entry, and the KV Store's four.

Waiting on each

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

The example's /timer path waits on two clocks through wasi:io, one set for 200 milliseconds and one for 500:

Rust
let started = Instant::now();
let short = monotonic_clock::subscribe_duration(200_000_000); // 200 ms
let long = monotonic_clock::subscribe_duration(500_000_000); // 500 ms
let ready = poll::poll(&[&long, &short]);
let first = started.elapsed().as_millis();
long.block();
let second = started.elapsed().as_millis();
Terminal output
poll returned [1] after 201 ms
block on the long timer returned after 501 ms

poll woke when the short timer fired and reported its index, 1. block on the long one waited out the rest.

/backend does the equivalent through async-io, with a request http-me takes a second to answer. The example sends it with send-async-uncached, so a second run can't be answered from the readthrough cache:

Rust
let slow = start(&http_me, "https://http-me.fastly.dev/wait=1000")?;
let result = async_io::select_with_timeout(&[&slow], 300);
let first = started.elapsed().as_millis();
let result2 = async_io::select_with_timeout(&[&slow], 5_000);
let second = started.elapsed().as_millis();
Terminal output
select-with-timeout(300) returned None after 301 ms
select-with-timeout(5000) returned Some(0) after 1028 ms

Both work, in the same program, and a real Compute service gives the same numbers to within a few milliseconds. The question is what happens when you want to wait on a timer and a backend at once.

They don't mix

The example has a mix feature that tries the obvious thing, passing a clock's pollable to select alongside a pending response:

Rust
let slow = start(&http_me, "https://http-me.fastly.dev/wait=1000")?;
let timer = monotonic_clock::subscribe_duration(300_000_000);
let _ = async_io::select(&[&slow, &timer]);

It doesn't compile:

Terminal output
error[E0308]: mismatched types
  --> src/lib.rs:57:39
   |
57 |     let _ = async_io::select(&[&slow, &timer]);
   |                                       ^^^^^^ expected `async_io::Pollable`, found `poll::Pollable`
   |
   = note: `poll::Pollable` and `async_io::Pollable` have similar names, but are actually distinct types

The compiler is reporting what the WIT says. A resource type belongs to the interface that defines it. select takes list<borrow<pollable>>, and in async-io that means async-io's pollable, not any resource that happens to share the name. Going the other way fails the same way: poll won't take a pending response. No function in either interface accepts both, so no single call can block on a timer and a backend together.

Why Compute has its own

Part of the answer is history. Before components, Compute programs called the host through flat hostcalls described in a .witx file, and the async part was a module called fastly_async_io:

Terminal output
(module $fastly_async_io
    ;;; Blocks until one of the given objects is ready for I/O, or the optional timeout expires.
    ;;;
    ;;; Valid object handles includes bodies and pending requests. See the `async_item_handle`
    ;;; definition for more details, including what I/O actions are associated with each handle
    ;;; type.
    ;;;
    ;;; The timeout is specified in milliseconds, or 0 if no timeout is desired.
    ;;;
    ;;; Returns the _index_ (not handle!) of the first object that is ready, or u32::MAX if the
    ;;; timeout expires before any objects are ready for I/O.
    (@interface func (export "select")
        (param $hs (list $async_item_handle))
        (param $timeout_ms u32)
        (result $err (expected $ready_idx (error $fastly_status)))
    )

    ...
)

That's select-with-timeout, down to "the index (not handle!)". async-io is that module carried into WIT, with the u32::MAX sentinel turned into an option and waiting without a timeout given a function of its own, select. Bodies and pending requests were already the things you waited on, so in WIT they became aliases of one resource.

History explains where async-io came from, but not why it wasn't rebuilt on wasi:io when the move to WIT happened. My reading is that the type system rules it out. A resource handle means something only to the instance that implements its type. For Compute's bodies and pending responses to be wasi:io pollables, whatever implements wasi:io/poll would have to be able to wait on them, which means the same implementation would have to own both wasi:io and every Fastly interface. On Compute today the host provides both, but the Component Model doesn't promise that. A component can import wasi:io from another component instead of the host, and that component would have no way to wait on a handle the host created.

WASI describes the same limit in its notes on WASI 0.3 (opens in a new window), released on June 11, 2026: "pollable is a resource scoped to a single component instance," which it calls the "sandwich problem." Its answer was to remove wasi:io altogether. In 0.3, "where WASI 0.2 used pollable resources, WASI 0.3 uses future<T> values," built into the Component Model's canonical ABI so the runtime can wake a waiter no matter which component made the thing it's waiting on.

async-io's doc comment already points the same direction:

WIT
/// Async IO support.
///
/// This module provides several utilities for performing I/O asynchronously.
/// See the documentation for `async-io.pollable` for a description of the kinds
/// of events it supports.
///
/// In the future, this interface is expected to be replaced by
/// [integrated async features].
///
/// [integrated async features]: https://github.com/WebAssembly/component-model/blob/main/design/mvp/Async.md#-async-explainer (opens in a new window)
interface async-io {
  ...
}

Both pollables are interim, then. compute.wit is still on WASI 0.2.6, so for now a Compute program has both.

Beyond the WIT

For a binding, two pollables means choosing which one the event loop blocks on. An async runtime, whether a Rust executor, a Go scheduler, or a JavaScript event loop, wants a single call that sleeps until the next thing it cares about is ready. On Compute, almost everything it cares about is a Fastly handle, so that call has to be select-with-timeout.

Timers then become the loop's own job. Instead of subscribing to a clock, the runtime keeps its timers in the guest, reads monotonic-clock.now() (which never blocks), and passes the time until the nearest deadline as select-with-timeout's timeout. When the call comes back with none, a timer is due.

Diagram of one pass through an event loop whose timers live in the guest. On the guest side, a list of timers has deadlines at 120, 300, and 450 milliseconds, and 120 is the nearest. The guest calls monotonic-clock.now on the host, which never blocks, and gets back 100 milliseconds. The guest computes the timeout as 120 minus 100, which is 20 milliseconds, and calls select-with-timeout on the host with its Fastly handles and a timeout of 20. The host waits on the Fastly handles only. When nothing is ready after 20 milliseconds, it returns none, and the guest runs the 120 millisecond timer. The host never sees the timers, only a timeout. guest host timers, kept in the guest deadline 120 ms nearest deadline 300 ms deadline 450 ms now = 100 ms read the clock returns 100 monotonic-clock.now() never blocks timeout = 120 - 100 = 20 milliseconds until the nearest deadline 20 select-with-timeout(handles, 20) waits on Fastly handles only none, after 20 ms run the 120 ms timer none means a timer is due the timers stay in the guest; the host only ever sees a timeout

That loop runs into two inputs compute.wit doesn't define. When a timer is already overdue, the timeout is 0. When there are timers but no Fastly handles, the list is empty. There are two ways to deal with them.

The first is to rely on empirical behavior: what the hosts actually do. Both Viceroy and a real Compute service treat a timeout of 0 as "don't wait": the call checks the list and returns right away. Given an empty list, both wait out the timeout and return none, which makes the call a sleep. With those two behaviors, every pass through the loop makes the same call:

Diagram of an event loop that relies on how the hosts behave. Each pass computes the timeout as the time until the nearest deadline, which is 0 when a timer is already overdue, then calls select-with-timeout with the list of Fastly handles, which may be empty. If it returns some index, that handle is ready and its I/O runs without blocking. If it returns none, the timeout expired and the due timers run. Either way the loop goes back to computing the timeout, so every pass makes the same call. timeout = nearest deadline - now 0 when a timer is already overdue select-with-timeout(handles, timeout) the list of Fastly handles may be empty some(i) none handle i is ready its I/O runs without blocking run the due timers the timeout expired the same call every pass, relying on how the hosts treat 0 and []

Neither behavior is documented, and the fastly_async_io doc comment above listed 0 to mean no timeout at all, but this is how the hosts work today, and consider how breaking it would be, I'd be surprised to see either one change.

The second is to rely only on what the WIT promises, and never pass either input. A timer that's already due runs without a call to the host. A loop with no Fastly handles blocks on a clock's pollable through wasi:io instead, which is fine here because there's nothing for async-io to report. Everything else goes to select-with-timeout with a timeout of at least 1 millisecond, rounded up so that a deadline less than a millisecond away doesn't become 0:

Diagram of an event loop that passes select-with-timeout only inputs the WIT defines. Each pass first asks whether a timer is already due; if so, it runs the due timers with no call to the host. If not, it asks whether there are any Fastly handles; if there are none, it subscribes to a monotonic clock for the time until the nearest deadline and blocks on that pollable through wasi:io, since nothing else is pending. If there are handles, it calls select-with-timeout with them and a timeout in milliseconds rounded up to at least 1, then handles the ready handle or runs the due timers. Every branch goes back to the first question. is a timer already due? yes run the due timers no call to the host no any Fastly handles? no subscribe-duration + block wasi:io, with nothing else pending yes select-with-timeout(handles, ms) ms rounded up, at least 1 handle i, or run due timers some(i) or none only inputs the WIT defines: never a 0 timeout, never an empty list

The mistake to avoid is blocking on a wasi:io pollable while Fastly work is pending. A standard-library sleep is one of those, because it waits on a clock through wasi:io. The backend response still arrives, and the host holds it, but the program doesn't wake up for it until the sleep ends. Your language's sleep isn't wrong on Compute, just blind to everything async-io would have told it.

Next: wasm-tools component wit came up last time, as a way to list a component's imports. It prints much more than that. A built component carries its own WIT, and you can read it back out.