WebAssembly in a Homemade Language: Where Mere Stands (September 2026)

A status note on one language's Wasm backend: capabilities lowered to WASI interfaces, real network programs running as components on two hosts, a threading wall that turned out not to be WASI's, and a playground that had quietly tripled in size because nobody was measuring it. The whole of it is 1,064,718 bytes down to 616,025.

merewebassemblywasicomponent-modelcompilersbenchmarks

Mere is a small language I write for myself. It has an interpreter and four compiled backends – C, LLVM IR, WebAssembly text, and RISC-V machine code – and the Wasm one is about 11,400 lines. This note is a status report on that backend: what it can do in September 2026, what it cannot, and a day spent finding out that a number nobody was watching had tripled.

It is a measurement note. Every figure below came off this machine or out of CI, and the unflattering ones are in the same tables as the good ones.

One design detail is worth stating up front, because it shapes everything else. The backend emits WebAssembly text, not binary. wat2wasm from wabt assembles it. That means the output is readable, diffable, and greppable at every stage, which is why several of the measurements below were possible at all – I could ask questions of the artifact by reading it.

Capabilities became WASI interfaces

The first arc was the Component Model. Mere had grown a set of host capabilities the way small languages do: print writes a line, args returns the command line, read_file reads a file. Each of those was an ambient import from a module called env, which is exactly the shape that cannot be componentized – a component declares what it needs in its world, and env.* declares nothing.

So each capability was lowered onto a WASI interface instead:

capability lowered to
print fd_write
args args_get
env_var environ_get
time clock_time_get
stdin fd_read
read_file / write_file path_open + fd_read / fd_write
TCP / UDP / DNS wasi:sockets (p2, resources)

The filesystem half came cheaper than expected. The preview-1 adapter’s path_open family carries it, so the resource-and-stream model of preview 2 never had to be touched for files. Sockets were the opposite: preview 1 cannot create a socket at all, so those go straight at wasi:sockets with resource handles, pollable.block, and the canonical ABI’s flattening of records and variants into a dozen i32 parameters.

The result is that real programs – not test cases – run as components. An HTTP client written in Mere fetches a page over a real socket. A DNS resolver written in Mere sends a UDP query to 8.8.8.8 and reads an A record back. Both run as one artifact on two hosts: wasmtime natively, and jco under Node. That two-host property was deliberate, and it is worth more than it looks: a component that runs on exactly one runtime has not really been tested against the spec, only against an implementation.

The version is pinned at wasi:*@0.2.3, in one place. It used to be written out at each of thirty-two imports, plus eight more in the build script, which is one rule spelled forty times: the release that moves it would have had to be right in all of them. The build script no longer repeats it either – it reads the version back out of the module the compiler just emitted, so the world it embeds cannot name a version the imports do not use.

The wall that was not WASI’s

One dogfood did not make it: a Redis-protocol key-value server. It spawns a thread per connection and talks to a store thread over channels – sharing by communicating, with the handlers never touching the map.

The Component Model is single-threaded. Its canonical ABI does not contemplate shared-memory threads, and wasi-threads is a core-module feature that is incompatible with it. So I filed the gap, wrote down the options, and set the revisit condition: when component-model async matures in WASI 0.3.

WASI 0.3.0 shipped on 11 June 2026. I went back to check, and found that both halves of what I had written were out of date, in opposite directions.

The tooling half was satisfied: wasmtime 46 carries -S p3=y. But threading is explicitly out of scope for WASI 0.3. What 0.3 added is async func, stream<T> and future<T>concurrency, with the host running one event loop, not parallelism. My revisit condition could never be met, no matter how mature 0.3 became.

Reading the server’s source again settled the rest. One spawn per connection, channels, handlers that never touch the store: that is I/O-bound CSP, and it does not need parallelism. The shape fits cooperative scheduling perfectly.

But WASI 0.3 defines the async ABI at the component boundary, not a way for a guest to suspend in the middle of a computation. A core module compiled from a direct-style language still needs its own state machine or stack switching. That was already on my options list – a green-threads runtime, noted at the time as the hardest of them – and 0.3 shipping did not remove a single line of it.

The wall was never the adapter. I had attached a deferral to the name of an external event, and the event arrived carrying an answer to a different question. The honest version is: this needs a continuation mechanism on my side, and starting it is a decision, not a wait.

Nobody was measuring the bytes

The documentation site has a playground: fifteen demos compiled to .wasm and served to browsers. Nothing measured them.

I went to look, found the checked-out build directory, and read numbers off it. They seemed fine. Then I noticed the directory was two months old – gitignored, never rebuilt. Rebuilding from source gave a different picture entirely:

July build (stale) actual
files 10 15
total 632 KB 1,064,718 B
hello.wasm 5.2 KB 15.0 KB

hello.wasm had tripled in two months and nobody said anything, because the size of a build output is not something any other check looks at. Reading a stale tree is also how I first concluded it was “comfortably small” – a measurement of the wrong artifact reads exactly like a measurement.

For context: the Web Almanac’s 2025 crawl puts the median real-world .wasm at 14 KB. A program that prints one line was above the median for the whole web.

So there is a gate now. It builds the real site into a temporary directory – never the checked-out one – and compares every module against a band with both a floor and a ceiling. The ceiling is the regression everyone expects. The floor is the half that matters more: a demo that collapses to a stub is a failure that a ceiling-only check calls a pass, forever.

What the optimizer could not take

wasm-opt -Oz was not being run anywhere. Adding it took 34.1% off the playground: about -48% on the small demos, -27% to -33% on the self-hosted compiler builds. Every result still validates, and the demos that can run headlessly still answer exactly what the interpreter does.

What it could not take is the more interesting number, and one column gave it away. The elem count did not move in a single file – 65 stayed 65, 627 stayed 627 – while function counts dropped by a third.

Every closure call in Mere goes through the function table, so every entry in the elem segment is a root the optimizer may not remove: call_indirect could target it. hello.wasm kept 101 of its 152 functions through -Oz, and 65 of those were table entries.

To find out what that was worth, I took the table out of the module by hand – a broken artifact, built only to bound the headroom – and split it two ways:

hello.wasm, after -Oz bytes functions
as shipped, 65 roots 7,915 101
without the 34 top-level fn adapters 7,267 58
without the 31 prelude lambdas 4,596 54
without either 2,299 7

Dropping the 34 adapters removes 43 functions and 648 bytes. Dropping the 31 lambdas instead removes 4 functions and 3,319. The cheap-looking half was cheap; the two are super-additive because they keep each other alive; and a program that prints one line genuinely needs seven functions.

That reframed the work. It was not “narrow the table” – the entries are statically reachable, and there are 51 call_indirect sites in that module. It was “stop emitting prelude functions the program cannot reach,” because emitting a function body is what puts its lambdas in the table.

The prelude the program never calls

So the backend now computes reachability over the AST, seeded from the main body, and emits only what it reaches. hello.wasm went to 1,946 bytes and 8 functions, with one entry in the table.

Two constraints shaped it. Only the prelude is pruned: every function the user wrote stays a root whether or not the walk sees a use of it, which keeps the blast radius inside code they did not write. And the walk has no catch-all arm, so a syntax node added later stops the build rather than silently dropping the names underneath it. Over-approximating costs a function nobody calls; under-approximating emits a call to a function that was never laid down, which wat2wasm refuses by name.

Which is how the one real hole announced itself. A monomorphized specialization is named <base>__<type tags>, and nothing in the source ever says that name – the call site says list_map and the emitter writes list_map__list_top_decl__closure_top_decl_top_decl__list_top_decl. Reaching the base has to reach every instance of it. The self-hosting bootstrap test caught that within one run.

The second hole was older than my change and only exposed by it. A call_indirect needs a table to call through even when nothing is registered in it, and the table was being declared only when a flag named for one construct was set – the higher-order vector helpers, the single case that had been seen to need it. That held only because something else kept the table non-empty for every program: these very adapters. Pruning removed the accident, and a bytes-to-vector bridge in the differential corpus stopped assembling with table variable out of range: 0 (max 0).

My first fix for that was wrong in a quieter way. I keyed it on “does the emitted text contain a call_indirect” – but the table section is built before the main function’s body and several runtime sections exist as strings, so the scan would have read a subset and answered for the whole. The table is declared unconditionally now. It costs about twenty bytes in a module that never calls through it, and it cannot be wrong.

Where it all landed:

before after
playground total 1,064,718 B 616,025 B
floor (smallest module) 15,329 B 1,662 B
hello.wasm 15,403 B / 152 fns 1,946 B / 8 fns

Prune rates track what you would expect from the programs: hello keeps 1 of 34 top-level functions, a word counter 2 of 35, a 2048 implementation 26 of 59, and the self-hosted compiler 287 of 319.

Twenty assertions that were not looking at their subject

The pruning broke twenty unit tests, and every one of them was already wrong.

They were substring assertions over emitted Wasm – “compiling this program produces i32.store offset=4” – describing the memory layout from before the value representation widened to 64 bits. They had been false for a long time. They passed because the string turned up in runtime helper functions that the program under test does not use. Pruning took the helpers away, and the accidental match went with them.

So I stopped fixing them one at a time and swept all 97 assertions of that shape. Thirteen more were false. Every replacement was then read off the program’s own main function and checked to be absent from the trivial program 0 – which is the check the originals never had, and why they survived a layout change.

The sweep found something else: 41 of the 97 also match the program 0. They are not false, only vacuous – they would pass for almost anything, because the runtime contains every opcode they name. Those are left alone and written down. They are a different piece of work, and pretending a sweep fixed them would be its own kind of false green.

There is a matching story on the instrument side. When I poisoned the size gate by substituting a different valid module for hello.wasm, the size check called it green – the substitute landed inside the band – and the thing that caught it was the behaviour check that runs the shipped module against the interpreter. A gate that only knows how big something is cannot tell you it is the wrong thing.

What the instruments did to me

Three of the day’s failures were mine, and they rhyme.

The gate was red in CI from the moment it landed, for three commits. The site build starts with a dune exec, and in CI the build tool is only on the path inside the package manager’s environment. The deployment workflow had always invoked that script correctly; I wrote the CI step by copying the shape of the gates next to it, which need no such thing because they call the compiler binary directly. The neighbour was the wrong model. The gate’s own report made it worse – “the site build failed” over a one-line “command not found” – so the first guess from it was wrong too.

The optimizer version turned out to be part of the measurement. I pinned the toolchain the way the repository pins everything else, except for binaryen, which I let the distribution supply. Ubuntu 24.04 ships version 108, which cannot read these modules at all – it rejects the tail calls without a flag, and rejects a mutable exported global with one. Bands are byte counts, so the optimizer that produced them belongs in the pin. Version 132 emits the recorded bytes exactly on Linux x86-64 and macOS arm64 alike.

And I re-ran the gate I had just written, but not the one that had been watching the same quantity all along. A separate budget check has measured example-server Wasm sizes for a long time, with the same kind of floor. The prune pushed all three under it. CI caught what I did not. Having built an instrument for a number, I had stopped looking for the instruments already pointed at it.

Running the new gate once on the CI image caught a fourth, before it shipped: the container’s Node 18 refuses opcode 0x12, and the behaviour check reported that as “the optimized module does not answer what the interpreter does.” A failure that names the wrong tool is worse than no failure at all, so the precondition is asked up front now, and CI asserts the capability rather than the version.

What is left

Mere does not use WasmGC. It ships its own bump allocator and its own closure representation in linear memory. The ecosystem’s headline size story for managed languages in 2026 is exactly the opposite move – hand memory management to the host and stop shipping a collector – and the reported savings are real. Whether that trade is right for a language that also targets a CPU it wrote for itself is an open question, and not one I have measured.

The 41 vacuous assertions. Weak, not wrong, and each needs its own discriminating input.

Threading, correctly filed now: a continuation or cooperative-scheduling mechanism in the compiler, which is a decision rather than a wait.

wasm-opt cannot process a component, which is upstream and open. Since preview 2 the component is the default artifact, so the most effective optimization pass currently skips the thing you ship.

Of the Wasm 3.0 feature set, this backend uses tail calls and nothing else – no GC, no exception handling, no 64-bit memory, no atomics. That is a fair summary of where it stands: a linear-memory compiler with a componentized capability layer, a browser playground that now costs a quarter of what it did, and one wall it knows the shape of.

← Back to Notes