> blog // target

Neper writes WebAssembly itself

By Neper · · Measured with Neper's own compiler on Windows, Node 24, Chrome, and an iPhone simulator; every number is from a run, and the limits are listed

The compiler that writes Windows, Linux and macOS executables now writes WebAssembly the same way: its own emitter and its own file writer, with no LLVM, no Emscripten and no separate linker. A hello world is 18,250 bytes. A window with the real renderer and widgets is 1.7 MB. The compiler itself, compiled to WebAssembly, writes the same executables as the native one, byte for byte, and the same widgets run in a canvas on a phone.

18,250 Bhello world, release (7,060 gzipped)
1.7 MBa window with the real widgets (464 KB gzipped)
1,075of 1,142 fixtures match native in the threaded sweep
2,196of 2,266 executables neper.wasm writes are byte-identical
The same story in 2 minutes 24 seconds, with the real commands and sizes. Captioned, with a synthetic voice. Watch on YouTube.

One command

The target is wasm32-wasi: WebAssembly with WASI preview 1, the interface that Node and most runtimes already implement. It is one more argument to the command that writes native files.

$ neper emit-executable hello.e . wasm32 wasi hello.wasm --release
executable written
$ node lib/wasm/run-wasm.mjs hello.wasm
hello, neper

Three runs on a Windows machine took 497, 409 and 339 ms and wrote 18,250 bytes. The same program is 12,600 bytes as a native Windows executable. Nothing else was installed for it: no clang, no wasm-ld, no SDK. The only thing that has to exist at run time is a WebAssembly engine, which Node 24 and every current browser provide.

How it is built

A whole-program emitter

The compiler already turns a program into one typed intermediate representation, NIR, for its other back ends. src/wasm.e writes a module from it directly: types, imports, functions, data, and a name section so that a trap names the Neper function.

Structured control flow

WebAssembly has no goto. Every function the emitter writes is structured: loops, blocks and ladders recovered from the program's control-flow graph, with a dispatch-loop fallback that the test suite forces on to keep it honest. The compiler's own 3,330 functions all come out structured.

The data layout does not change

A pointer is still eight bytes in memory, so struct layout, compile-time results and checked usize arithmetic are identical to x64. In an expression a pointer is an i64 wrapped to i32 at each memory access. The price is one wrap per access and 4 GiB of memory; the way out is wasm64.

The runtime is Neper

What a program asks of its host lives in lib/e/os.wasi.e, an ordinary Neper file, the way NeperOS's is.

What a module can do

Files, directories, the clock, random numbers and the environment go through WASI. Everything else WASI does not offer comes through a small import module, neper_host, that the runner supplies. These are the parts that were built and exercised:

Sockets and a poller

TCP and UDP, name resolution, and the poller that e.net sits on. The network fixtures (TLS, SMTP and POP3 among them) run in a module and talk over real loopback sockets.

Processes and pipes

A module can start a program, read its output through a pipe and wait for it, under Node. A browser page has no processes, so there the same calls answer Unsupported.

Threads

--threads writes a module with shared memory and real atomics. The thread runtime is Neper code; the runner starts each thread as a worker. In a page it needs the cross-origin isolation headers.

System information

CPU times, memory, uptime, disks and the process table come from the runner as the same text /proc gives, so Linux's parsers read them unchanged. A page that has none answers Unsupported.

Checked against native

A sweep builds every link fixture for the host and for WebAssembly, runs both, and compares the exit status, standard output and standard error. Of 1,142 fixtures in the threaded sweep, 1,075 matched at the last full count; each of the remainder was then either fixed or written down with its cause:

What differsWhy, and whose limit it is
Memory-mapped files, file locks, signals, dynamic librariesWASI has none of them. The program gets Unsupported rather than a pretence: a copy cannot keep a mapping's promise.
Desktop services: opening a URI, revealing a file, the trashA module has no desktop. Windows and GPU fixtures print the unsupported line.
Reading a font by a host pathA module cannot reach C:\Windows\Fonts or a Linux path outside its preopened folders.
Killing a process tree on WindowsNode on Windows cannot kill a descendant that outlived its root.

The sweep keeps these in a file, tests/wasm/sweep-expected.tsv, and reports a run as expected only when the host and both exit statuses match a row. A new regression in one of them therefore still shows as a failure.

The Linux suite and the WebAssembly sweep pass in CI at the last full run on master, and a curated set also runs on macOS 14, plain and threaded. That is where a difference between epoll and kqueue turned up: adding an already-registered file descriptor replaces it silently on macOS, so the poller now keeps its own table.

The compiler runs as WebAssembly

The strongest test of an emitter is the largest program you have. src/main.e, the compiler, written for wasm32-wasi, is a 4,689,531-byte module of 3,330 functions. Run under Node, it compiles programs for other targets:

Result of 2,266 executables written by neper.wasmCount
Byte-identical to the native compiler's2,196
Differing only in the embedded compiler identity (device code carries the compiler's own checksum)58
Refused, with the same diagnostics as the native compiler10
Different in one constant that the native compiler gets wrong at its default worker count (gpu_tensor)2

It also compiles itself: neper.wasm writes src/main.e for wasm32-wasi and the bytes equal the module the native compiler wrote, a fixed point. It costs time and memory. Writing the compiler for x64 Windows takes 1.6 s native and 5.6 s as WebAssembly, with a 986 MB working set. For a WebAssembly target it is 1.8 s against 3.1 s.

In a browser, and on a phone

The same e.ui widgets that draw a native window draw into a canvas: the renderer produces CPU frames, the page presents the newest one each animation frame, and input crosses to the module through a shared ring. A module that is a whole gallery of widgets is 6.5 MB in release form; here it is in Chrome:

The e.ui widget gallery in a Chrome canvas: text fields, a chooser, an error message under a required field.
The widget gallery's Fields tab, one .wasm file in a Chrome canvas. The page is cross-origin isolated, which threads need.

A canvas is not editable, so a phone shows no keyboard for it. The page keeps a hidden text field beside it and forwards what that field receives. We checked that on an Android phone, and it found a bug that no desktop test could. Android's keyboard does not compose text for Bulgarian or English; when you tap a suggestion it sends one delete event that removes the whole half-typed word, then the replacement. The window turned each delete event into one Backspace, so correcting outdige to outside left the old letters in the program. The text field now keeps what was typed and the page reads the edit from the field's value before and after.

A finger has no right button, so a hold selects the word, dragging extends the selection, and on lift a small toolbar offers Cut, Copy, Paste and Select all. We confirmed it on the Android phone, then drove the same page in Mobile Safari on an iPhone simulator on a macOS runner (iOS 17.5, through Appium): typing on the on-screen keyboard, Backspace and the hold toolbar all reach the program.

The counter window in Mobile Safari on an iPhone simulator: the name field holds 'hel', a toolbar with Cut, Copy, Paste and Select all is above it, and the iOS keyboard is open.
The counter window in Mobile Safari on an iPhone simulator running iOS 17.5, after a hold on the text. The on-screen keyboard is iOS's own.
Where it stops

Not yet on a real iPhone, and not with a composing keyboard such as Japanese or Chinese. Selection has no draggable end handles: that belongs in e.ui's text field, which lists touch handles as not built. A page has no sockets or processes, so those calls answer Unsupported. A module's memory is at most 4 GiB, and there is no GPU device inside one.

Try it

$ neper emit-executable app.e . wasm32 wasi app.wasm --release
$ neper emit-executable app.e . wasm32 wasi app.wasm --release --threads
$ node scripts/serve-wasm.mjs --isolated    # a page with the headers threads need

Node 24 or a current browser runs the result. The window page is tests/wasm/browser/ui_counter.html, and lib/wasm/neper-host.js is the host a page of your own passes the module to.

Honest limits of these numbers

Sources

The sizes and the phone checks were measured on October 26 and 27, 2026; the compiler-as-WebAssembly and sweep figures on October 25, when that work landed. All of them drift. If one here is wrong or out of date, we want to know.

Related posts

A 1 TiB ceiling that costs 3 MiB · ClearType text in Neper