The Wasm Component Model on Fastly Compute
Two Pollables, One World
October 14, 2026, by Kats Omuro (@katsuyukiomuro (opens in a new window) on X/Twitter)
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:
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:
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:
pollreturns every ready index, as alist<u32>.selectandselect-with-timeoutreturn only the first.pollhas no timeout. Its doc comment says to add a clock's pollable to the list instead.async-iobuilds the timeout intoselect-with-timeout, in milliseconds.- A WASI pollable is something you subscribe to. A monotonic clock hands you one with
subscribe-duration, and a stream hands you one withsubscribe, separate from the stream itself. Incompute.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, includingpending-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:
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();
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:
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();
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:
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:
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:
(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:
/// 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.
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:
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:
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.