The Wasm Component Model on Fastly Compute
The Same Tally, in JavaScript
October 23, 2026, by Kats Omuro (@katsuyukiomuro (opens in a new window) on X/Twitter)
EDIT (2026-10-23): added why this post picks JavaScript, in the opening, and corrected what Beyond the WIT says about where the no-files stub could go.
EDIT (2026-10-25): corrected where the refusal happens (at activation, not at the upload), and added that the 0.2.12 WASI imports fail too.
The Same Tally, in MoonBit swapped tally's language and left the Rust service alone, but MoonBit was a gentle test. It compiles to a core module with a linear memory, the way Rust does, and its build differed mostly in running the second stage by hand. JavaScript is about as far from Rust as a Compute language gets. It's garbage-collected, and it doesn't compile to Wasm at all. It's also one of the two languages Resources Aren't Host-Only singled out when it warned that a garbage-collected binding has to keep exported objects alive by itself. If tally really can be written in anything that targets components, JavaScript is the case worth trying.
The JavaScript version is a class with a constructor, an add method, and a destructor, and it fits in twenty lines. You'd expect the smallest source in this run of posts to make the smallest component. It makes the largest by far. It imports the filesystem without a line of file code, needs the no-files component from What the World Leaves Out to get past that, and even then a real Compute service won't take it.
Twenty lines
Full working code: full example on GitHub (opens in a new window).
The tally interface and the service world are unchanged from Resources Aren't Host-Only, and so is the Rust service. Here's the whole tally component:
// The resource's representation: an ordinary JavaScript class.
class Tally {
constructor(name) {
this.name = name;
this.total = 0;
console.error(`tally: created ${name}`);
}
add(n) {
this.total += n;
return this.total;
}
// Called when the last handle is dropped.
[Symbol.dispose]() {
console.error(`tally: dropped ${this.name} at ${this.total}`);
}
}
export const tally = { Tally };
The export is an object named after the interface, holding a class named after the resource. There are no reps in sight. The glue picks them and keeps track of which object each one stands for, the job MoonBit left to you, and calls [Symbol.dispose]() when the destructor call arrives.
The tool that turns this into a component is jco (opens in a new window)'s componentize command, from the Bytecode Alliance:
jco componentize tally.js --wit ../wit --world-name tally-provider -o tally.wasm
JavaScript doesn't compile to Wasm the way Rust or MoonBit does, so componentize doesn't try. It builds the component around a JavaScript engine, StarlingMonkey (opens in a new window), a JavaScript runtime built on Mozilla's SpiderMonkey, and your script runs inside that.
What the engine brings
The engine is most of the component. tally.wasm is 12.5 MB, against 19 KB for MoonBit's release build. Its import list on the first try:
import wasi:io/poll@0.2.12;
import wasi:clocks/monotonic-clock@0.2.12;
import wasi:io/error@0.2.12;
import wasi:io/streams@0.2.12;
import wasi:http/types@0.2.10;
import wasi:http/outgoing-handler@0.2.10;
import wasi:cli/stdin@0.2.12;
import wasi:cli/stdout@0.2.12;
import wasi:cli/stderr@0.2.12;
import wasi:cli/terminal-input@0.2.12;
import wasi:cli/terminal-output@0.2.12;
import wasi:cli/terminal-stdin@0.2.12;
import wasi:cli/terminal-stdout@0.2.12;
import wasi:cli/terminal-stderr@0.2.12;
import wasi:clocks/wall-clock@0.2.12;
import wasi:filesystem/types@0.2.12;
import wasi:filesystem/preopens@0.2.12;
import wasi:random/random@0.2.12;
export local:main/tally;
Two things stand out besides the length. The wasi:http imports are there so the engine can offer fetch, which The service World showed Compute doesn't have. And everything is at WASI 0.2.12 or 0.2.10, newer than the 0.2.6 in compute.wit.
componentize can switch engine features off. --disable all --enable stdio keeps only what console.error needs, and that removes wasi:http, wasi:random, and the monotonic clock. The filesystem imports stay, with every feature off. They don't belong to any feature the flags control, and this script never touches a file.
No files, again
Composed with the service as it is, the component fails in Viceroy the way What the World Leaves Out predicted:
ERROR component imports instance `wasi:filesystem/types@0.2.12`, but a matching implementation was not found in the linker
The fix from that post works here too, with one change in how it's applied. wac plug only fills the imports of the component you give it first, and the filesystem imports belong to tally, not to the service. So the no-files component goes into tally, and the result goes into the service:
wac plug tally/tally.wasm --plug target/no_files.wasm -o target/tally-plugged.wasm
wac plug target/wasm32-wasip2/debug/service.wasm --plug target/tally-plugged.wasm -o bin/main.wasm
The no-files component exports wasi:filesystem at 0.2.6, and tally imports it at 0.2.12. wac connected them anyway, since 0.2.x versions are meant to be compatible. The composed component's remaining WASI imports are a mix, 0.2.6 for the two only the service uses and 0.2.12 for the rest, and Viceroy's linker accepted all of them. It answers bytes reached 42, and the log is the one every version of tally has printed:
tally: created requests
tally: created bytes
tally: dropped bytes at 42
service: dropped its handle to bytes
tally: dropped requests at 1
[Symbol.dispose]() ran inside the engine when the Rust service dropped its handle, before the service's next line, the same as Drop in Rust and Tally::dtor in MoonBit. Each request used 11.7 MB of Wasm heap, where the MoonBit version used 1.2 MB.
Three is one too many
fastly compute publish uploaded the package, then failed to activate it:
ERROR: the Fastly API returned 400 Bad Request: Bad request (ERROR: xqd-codegen failed: exit status: 1
Caused by:
The component transitively contains 3 Wasm linear memories, which exceeds the configured maximum of 2 in the pooling allocator).
Every component in this composition was built from a core module with its own linear memory: the service, the JavaScript engine, and the no-files component. That's three. Every composition earlier in this series had two components, and so two memories, which is why none of them ran into this.
I couldn't find the limit in Fastly's documentation. The message comes from Wasmtime's pooling allocator (opens in a new window), which has a setting for the maximum number of linear memories one component may contain, and a real Compute service seems to have it set to 2. Viceroy doesn't enforce it, so it ran a component that Compute wouldn't accept.
So this version of tally runs in Viceroy and nowhere else. Getting it onto a real service means getting back to two memories, and none of the pieces can just be dropped. The service has to stay, JavaScript needs its engine, and the engine needs something to answer for the filesystem. Two memories wouldn't be enough, either. The engine's WASI imports are at 0.2.12, and From Core Module to Component saw a real service refuse a component over its WASI version. A later publish of the same build reported that failure alongside the memory count: "missing import named wasi:io/poll@0.2.12."
Beyond the WIT
A component carries its language's runtime with it, and on Compute that runtime has two costs the WIT never mentions. One is imports: whatever the runtime reaches for, the world has to provide, or another component has to. The other is memory: each component that brings a core module brings a linear memory with it, and a real service allows two.
That makes composition on Compute a budget as well as a type check. wac checks that imports and exports line up, and Viceroy checks that they line up with its host, but neither counts memories. Three components passed both checks and failed at activation.
For a binding or a toolchain, the lesson is about where helpers go. Every helper shipped as its own component spends a memory, and after the service, Compute leaves room for one. What the World Leaves Out could afford a separate no-files component, because the service was the only other component. Here, tally had already spent that memory before the stub arrived. A runtime that reaches for imports outside the world is asking every program built on it to spend its one spare memory on a stub, which leaves nothing for a component like tally.
On the question Resources Aren't Host-Only raised, how a garbage-collected language keeps an exported object alive while another component holds a handle: the JavaScript side never held a reference to either Tally object after creating it, and both were still there when the drop calls arrived. The glue was keeping them, and [Symbol.dispose]() is where it let go.
Next: everything in this series has run under Viceroy first, and until now every finished example has deployed to a real Compute service as well. Builds have been refused by a real service before, but this is the first example where no build gets through. We look at what publishing involves, and what a real service checks that Viceroy doesn't.