The Wasm Component Model on Fastly Compute
KV Store: Read and Write at the Edge
August 14, 2026, by Kats Omuro (@katsuyukiomuro (opens in a new window) on X/Twitter)
EDIT (2026-08-18): corrected a claim that every store method but open returns kv-error (the four -async functions return plain error), and added comparisons against the real Rust SDK's KVStoreError and its blocking/async split.
Over the last two posts we looked at Config Store and Secret Store, which both look up values by key, but neither one lets you put a value there from within the Compute application—that happens outside compute.wit, through Fastly's UI or API.
The KV Store (opens in a new window) is used for the other cases—it's the one you write to from the same code that reads from it. It's a global, durable store that lets you store data in the form of key-value pairs for both reads and writes at the edge.
The KV Store interface
/// Interface to Fastly's [Compute KV Store].
///
/// For a high-level introduction to this feature, see this [blog post].
///
/// [Compute KV Store]: https://www.fastly.com/documentation/guides/concepts/edge-state/data-stores/#kv-stores (opens in a new window)
/// [blog post]: https://www.fastly.com/blog/introducing-the-compute-edge-kv-store-global-persistent-storage-for-compute-functions (opens in a new window)
interface kv-store {
use types.{error, open-error};
use http-body.{body};
/// A KV Store.
resource store {
/// Opens the KV Store with the given name.
open: static func(name: string) -> result<store, open-error>;
/// Looks up a value in the KV Store.
///
/// Returns `ok(some(v))` with the value `v` that was found, `ok(none)` if no value was
/// found, or `err(e)` indicating the error `e` occurred.
///
/// This function waits until the operation completes.
lookup: func(
key: string,
) -> result<option<entry>, kv-error>;
/// Look up a value in the KV Store asynchronously.
///
/// This function initiates an async lookup of a value in the KV Store. Use
/// `await-lookup` to finish the lookup.
lookup-async: func(
key: string,
) -> result<pending-lookup, error>;
/// Inserts a value into the KV Store.
///
/// If the KV Store already contains a value for this key, the `mode` field
/// of the `options` argument specifies how the existing value is handled.
///
/// This function waits until the operation completes.
insert: func(
key: string,
body: body,
options: insert-options,
) -> result<_, kv-error>;
/// Insert a value into the KV Store asynchronously.
///
/// If the KV Store already contains a value for this key, the `mode` field
/// of the `options` argument specifies how the existing value is handled.
///
/// This function initiates an async insert of a value in the KV Store. Use
/// `await-insert` to finish the lookup.
insert-async: func(
key: string,
body: body,
options: insert-options,
) -> result<pending-insert, error>;
/// Deletes a value in the KV Store.
///
/// Returns `ok(true)` if a value was successfully deleted, `ok(false)` if no value was
/// found, or `err(e)` indicating the error `e` occurred.
///
/// This function waits until the operation completes.
delete: func(
key: string,
) -> result<bool, kv-error>;
/// Delete of a value in the KV Store.
///
/// This function initiates an async delete of a value in the KV Store. Use
/// `await-delete` to finish the lookup.
delete-async: func(
key: string,
) -> result<pending-delete, error>;
/// Lists keys in the KV Store.
///
/// Returns `ok(b)` with the body `b` on success, or `err(e)` indicating the error `e`
/// occurred.
///
/// This function waits until the operation completes.
%list: func(
options: list-options,
) -> result<body, kv-error>;
/// List of keys in the KV Store.
///
/// This function initiates an async list value in the KV Store. Use
/// `await-list` to finish the lookup.
list-async: func(
options: list-options,
) -> result<pending-list, error>;
}
...
}
There are four operations, each with a plain form and an -async form: lookup, insert, delete, and list. That's more surface area in one resource than we've seen from any interface so far among the data stores—or, really, since http-req.
One syntax detail worth pausing on: %list. list is a built-in WIT type constructor (list<u8>, which you've seen constantly), so it can't be used bare as a function name—% is WIT's escape for using a reserved word as an identifier anyway. It's source-only: the generated Rust binding just calls it list, no %, no escaping needed at the call site. You'll only ever see the % in the WIT text itself.
kv-error: a fourth error type, sized to what actually goes wrong here
The four blocking operations return kv-error rather than the plain error we've used everywhere else. That's the fourth distinct error type this series has met, after error, send's error-with-detail, and open-error:
/// A value indicating the status of a KV store operation.
variant kv-error {
/// KV store cannot or will not process the request due to something that is perceived to be a
/// client error.
///
/// This will map to the api's 400 codes.
bad-request,
/// KV store cannot fulfill the request, as defined by the client's prerequisites, for example
/// `if-generation-match`.
///
/// This will map to the api's 412 codes.
precondition-failed,
/// The size limit for a KV store key was exceeded.
///
/// This will map to the api's 413 codes.
payload-too-large,
/// The system encountered an unexpected internal error.
///
/// This will map to all remaining http error codes.
internal-error,
/// Too many requests have been made to the KV store.
///
/// This will map to the api's 429 codes.
too-many-requests,
/// Generic error value.
///
/// This means that some unexpected error occurred.
generic-error,
/// Additional error information may be added in the future via this resource type.
extra(extra-kv-error),
}
Look back at the interface, though, and the four -async functions aren't using it. lookup-async, insert-async, delete-async, and list-async each return result<pending-*, error>, with the plain error type this series has had since the beginning. That's not an oversight. Starting an operation can only fail in generic ways, so the operation's own failure modes have nowhere to appear yet; they turn up later, on the await-* side, which does return kv-error.
Each variant's doc comment names the HTTP status class it maps to. That's not an accident of phrasing—the KV Store is a real networked service behind the scenes, and its error shape reads like one: rate limits, preconditions, payload limits. If you compare that to error's grab-bag of buffer sizes and generic failures, you can see the ABI tailoring its error granularity to what's actually likely to go wrong in each specific interface, rather than reusing one catch-all everywhere.
Where the ABI splits that granularity across two error types, Fastly's Rust SDK collapses it into a single KVStoreError (opens in a new window). Its variants cover what kv-error covers (ItemBadRequest, ItemPreconditionFailed, ItemPayloadTooLarge, TooManyRequests), plus what open-error covers (StoreNotFound), plus a few the ABI has no equivalent for at all, like InvalidStoreHandle.
The part worth staring at is where "not found" ends up, because the SDK moves it in both directions at once. KVStore::open returns Result<Option<Self>, KVStoreError>, documented as "if there is no store by that name, this returns Ok(None)," which pulls not-found out of the error type that open-error.not-found had put it in. Then lookup returns Result<LookupResponse, KVStoreError> and reports a missing key as the ItemNotFound variant, pushing not-found into the error type that result<option<entry>, kv-error> had deliberately kept it out of.
Neither is wrong. They're the same judgment call the ABI declined to make on your behalf, made twice in opposite directions inside one Rust type.
insert-options: a value, not a builder
/// Selects the behavior for an insert when the new key matches an existing key.
///
...
enum insert-mode {
/// Updates the existing key's value by overwriting it with the new value.
///
/// This is the default mode.
overwrite,
/// Fails, leaving the existing key's value unmodified.
///
/// With this mode, the insert fails with a code of `kv-error.precondition-failed`, and
/// does not modify the existing value. Inserts with this mode will only “add” new key-value
/// pairs; they are prevented from modifying any existing ones.
add,
/// Updates the existing key's value by appending the new value to it.
append,
/// Updates the existing key's value by prepending the new value to it.
prepend,
}
/// Options for configuring the behavior of the `insert` function.
record insert-options {
/// If set, allows fetching from the origin to occur in the background, enabling a faster
/// response with stale content. The cache will be updated with fresh content after the request
/// is completed.
background-fetch: bool,
/// Requests for keys will return a “generation” header specific to the version of a key. The
/// generation header is a unique, non-serial 64-bit unsigned integer that can be used for
/// testing against a specific KV store value.
if-generation-match: option<u64>,
/// Sets an arbitrary data field which can contain up to 2000B of data.
metadata: option<string>,
/// Sets a time for the key to expire. Deletion will take place up to 24 hours after the ttl
/// reaches 0.
time-to-live-sec: option<u32>,
/// Select the behavior in the case when the new key matches an existing key.
mode: insert-mode,
/// Additional options may be added in the future via this resource type.
extra: option<borrow<extra-insert-options>>,
}
insert-options is a record, not a resource—there's no constructor(), no step-by-step setters. Contrast that with dynamic-backend-options, which really was a builder: a resource with a constructor() and individual setter methods you called one at a time. Here, the whole configuration is one plain value you build in a single expression and hand over.
That's a different relationship to the ABI boundary entirely from response's:
Recall that a resource like response is never actually in your program; you hold a handle, and the real thing sits on the host the whole time. A record like insert-options has no host-side existence to point at—it's built entirely out of plain data in guest memory, and the entire value gets copied across the boundary fresh on every call.
append/prepend are worth a second look: they let you grow a value over multiple insert calls without reading the existing one back into your own code first, the same way http-body.append let you compose bodies without manually copying bytes through your code.
entry: a resource that loses part of itself
lookup doesn't hand back the value directly—it hands back an entry:
/// A response from a KV Store Lookup operation.
///
/// This type holds the `body`, metadata, and generation of found key.
resource entry {
/// Take and return the body from this `entry`, if it has one; otherwise return `none`.
///
/// After calling this method, this entry will no longer have a body.
take-body: func() -> option<body>;
/// Read the metadata of the KV Store item, if present.
metadata: func(max-len: u64) -> result<option<string>, error>;
/// Read the current generation of the KV Store item.
generation: func() -> u64;
}
take-body's doc comment says something no other resource method in this series has said yet: calling it changes what the resource itself has. A response never lost its headers because you read one; this entry genuinely loses its body the moment you take it. It's a small thing, but it's a real state transition happening on a handle you're holding, not just data being copied out.
Trying the plain functions first
Following the same shape as the last two posts, the obvious first move is to call the plain, blocking lookup / insert / delete—no pending-* handle, no await-*, just a direct call that waits for its own answer. Locally, against Viceroy, every one of them fails:
insert failed: KvError::InternalError
Every combination of options, every key, same result—on insert and on lookup alike. That's not a bug in the example. Viceroy's Wasm Component Model support is explicitly labeled "in active development" the moment you start fastly compute serve, and it turns out the plain, blocking KV Store functions are one of the pieces still missing: there's an open pull request, fastly/Viceroy#657 (opens in a new window), titled "Implement the blocking KV store WIT functions," from a Fastly engineer, still unmerged as of this writing. Its own description confirms exactly what we just hit: it implements list / lookup / insert / delete, "which are like their *_async counterparts, except that they wait for the result"—present tense, meaning they don't yet.
As with the dynamic backends same-name rule a few posts back, treat the plain
lookup/insert/delete/listfunctions as real and correct percompute.wit—they should work against production Fastly Compute. Locally, until #657 lands, reach for the-asyncsiblings instead.
The async siblings, and a payoff
Which brings us to the pieces that do work, and that we've actually already met. Each async operation hands back a pollable, under yet another local name:
/// An asynchronous KV Store lookup. Use `await-lookup` to resolve.
use async-io.{pollable as pending-lookup};
/// An asynchronous KV Store insert. Use `await-insert` to resolve.
use async-io.{pollable as pending-insert};
/// An asynchronous KV Store delete. Use `await-delete` to resolve.
use async-io.{pollable as pending-delete};
/// An asynchronous KV Store list. Use `await-list` to resolve.
use async-io.{pollable as pending-list};
Waiting on one of those pending-* handles works exactly the way it always has:
/// Wait on the async lookup of a value in the KV Store.
///
/// Returns `ok(some(v))` with the value `v` that was found, `ok(none)` if no value was
/// found, or `err(e)` indicating the error `e` occurred.
await-lookup: func(
handle: pending-lookup,
) -> result<option<entry>, kv-error>;
/// Wait on the async insert of a value in the KV Store.
///
/// Returns `ok` if the `insert` succeeded, or an error code on failure.
await-insert: func(
handle: pending-insert,
) -> result<_, kv-error>;
/// Wait on the async delete of a value in the KV Store.
///
/// Returns `ok(true)` if a value was successfully deleted, `ok(false)` if no value was
/// found, or `err(e)` indicating the error `e` occurred.
await-delete: func(
handle: pending-delete,
) -> result<bool, kv-error>;
/// Wait on the async list of keys in the KV Store.
///
/// Returns `ok(b)` with the JSON-encoded body `b` on success, or `err(e)` indicating
/// the error `e` occurred.
await-list: func(
handle: pending-list,
) -> result<body, kv-error>;
This is exactly the shape send-async/pending-response taught: kick off the operation, get a pollable back immediately under a local name, await-* it when you actually need the result. It's not the first time this series has met async-io.pollable wearing a different name—bodies, backend responses, and now four more from KV Store, all the same underlying resource. That's the whole reason concurrency got its own dedicated treatment instead of being re-taught locally in every interface that touches it: learn it once, recognize it everywhere after.
Don't let the name mislead you, though:
insert-asyncisn'tsend-async-streamingwearing a KV Store costume. Remember thatsend-async-streamingtookbody: borrow<body>—you kept the handle and could keep writing to it after the call returned, with the write happening in the background as you went.insert-asynctakesbody: body, the same owned valueinsertdoes: the whole body is handed over to the KV Store the moment you make the call.The action that
await-insertwaits on is the store finishing an insert it already has the full body for, not for you to finish writing one.
Read and write, in one request
mod bindings;
use bindings::{
exports::fastly::compute::http_incoming,
fastly::compute::{http_body, http_req, http_resp, kv_store},
};
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),
}
}
}
fn read_body_to_string(body: &http_body::Body) -> Result<String, http_body::Error> {
let mut buf = Vec::new();
loop {
let chunk = http_body::read(body, 8192)?;
if chunk.is_empty() {
break;
}
buf.extend_from_slice(&chunk);
}
Ok(String::from_utf8_lossy(&buf).into_owned())
}
struct KvStoreExample;
impl http_incoming::Guest for KvStoreExample {
fn handle(request: http_incoming::Request, request_body: http_body::Body) -> Result<(), ()> {
let key = get_header_value(&request, "x-key")
.map_err(|_| ())?
.unwrap_or_else(|| "demo-note".to_string());
let incoming_value = read_body_to_string(&request_body).map_err(|_| ())?;
let value = if incoming_value.is_empty() {
"Hello from the KV Store!".to_string()
} else {
incoming_value
};
let store = kv_store::Store::open("notes").map_err(|_| ())?;
let mut log = String::new();
let insert_body = http_body::new().map_err(|_| ())?;
http_body::write(&insert_body, value.as_bytes()).map_err(|_| ())?;
let insert_options = kv_store::InsertOptions {
background_fetch: false,
if_generation_match: None,
metadata: Some("written by the kv-store example".to_string()),
time_to_live_sec: None,
mode: kv_store::InsertMode::Overwrite,
extra: None,
};
let pending_insert = store
.insert_async(&key, insert_body, &insert_options)
.map_err(|_| ())?;
kv_store::await_insert(pending_insert).map_err(|_| ())?;
log.push_str(&format!("inserted \"{key}\" = \"{value}\"\n"));
let pending_lookup = store.lookup_async(&key).map_err(|_| ())?;
let entry = kv_store::await_lookup(pending_lookup)
.map_err(|_| ())?
.ok_or(())?;
let generation = entry.generation();
let metadata = entry.metadata(256).map_err(|_| ())?;
let entry_body = entry.take_body().ok_or(())?;
let read_back = read_body_to_string(&entry_body).map_err(|_| ())?;
log.push_str(&format!(
"looked up \"{key}\": value = \"{read_back}\", generation = {generation}, metadata = {metadata:?}\n"
));
let pending_delete = store.delete_async(&key).map_err(|_| ())?;
let deleted = kv_store::await_delete(pending_delete).map_err(|_| ())?;
log.push_str(&format!("deleted \"{key}\": {deleted}\n"));
let pending_lookup_2 = store.lookup_async(&key).map_err(|_| ())?;
let after_delete = kv_store::await_lookup(pending_lookup_2).map_err(|_| ())?;
log.push_str(&format!(
"looked up \"{key}\" again: {}\n",
match after_delete {
Some(_) => "still there",
None => "gone",
}
));
let response = http_resp::Response::new().map_err(|_| ())?;
response
.insert_header("content-type", b"text/plain")
.map_err(|_| ())?;
let out_body = http_body::new().map_err(|_| ())?;
http_body::write(&out_body, log.as_bytes()).map_err(|_| ())?;
http_resp::send_downstream(response, out_body).map_err(|_| ())?;
Ok(())
}
}
bindings::export!(KvStoreExample with_types_in bindings);
In one request, this code performs four operations against the store: insert a value, look it back up (reading its body, generation, and metadata), delete it, then look it up once more to prove it's actually gone. read_body_to_string is the same read-loop we've written since the body article—entry.take-body() hands back an ordinary body, so nothing about reading it needed to change.
Running it
Full working code: full example on GitHub (opens in a new window). fastly.toml just needs the store name declared, with no seed data required:
[local_server.kv_stores]
notes = []
fastly compute serve
curl http://127.0.0.1:7676/ (opens in a new window)
inserted "demo-note" = "Hello from the KV Store!"
looked up "demo-note": value = "Hello from the KV Store!", generation = 1785949892671600000, metadata = Some("written by the kv-store example")
deleted "demo-note": true
looked up "demo-note" again: gone
curl -H "x-key: my-note" -d "a value I wrote myself" http://127.0.0.1:7676/ (opens in a new window)
inserted "my-note" = "a value I wrote myself"
looked up "my-note": value = "a value I wrote myself", generation = 1785949892677652000, metadata = Some("written by the kv-store example")
deleted "my-note": true
looked up "my-note" again: gone
(Your own generation numbers will differ—it's documented as "unique, non-serial," not a counter you can predict.)
Beyond the WIT
The Viceroy gap here is worth sitting with for a second, past just working around it. compute.wit describes the plain lookup/insert/delete/list functions as fully real, and nothing in the WIT itself hints they're second-class. The gap only exists one layer down, in a specific tool's specific implementation state, and it's actively being closed. That's a useful distinction to hold onto in general: the ABI is the contract; a given host's conformance to it is its own fact, moving on its own schedule, worth checking directly (as we just did, against a real, citable, open PR) rather than assuming from a failure. We hit the exact same category of gap with dynamic backends' same-name rule: different bug, same lesson.
It's best to prefer the async form as your default anyway, in any host language that can express it comfortably, even after Viceroy gains support for the blocking calls. A blocking call ties up your instance for the KV Store's round trip; the async form lets that wait overlap with the rest of your request handling. The blocking form earns its keep mainly in a language without a good way to express "suspend here, resume when the pollable's ready," where the async ceremony would cost more than the concurrency buys you.
Fastly's Rust SDK carries both, and puts the blocking one in front. lookup/insert/delete/list are the plain methods on KVStore; the pending handles (PendingLookupHandle, PendingInsertHandle, PendingDeleteHandle, PendingListHandle) live behind the builder types instead, one level further in. That's a defensible default in a language whose ordinary control flow is synchronous, and it's still the opposite of the advice above, which is exactly the kind of decision worth making deliberately rather than inheriting.
Next: one more KV Store post before we move on—metadata and generation, the two insert-options fields this one quoted but glossed over, and what they're actually for.