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.
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.