I am, by no means, an expert with WebAssembly. I feel like I still barely understand it, and how to properly use it. But I am going to change that.
WebAssembly is a new hot technology that involves loading a bunch of code that resembles the classic Assembly language. How this differs from JavaScript, which the browser already understands natively, is that the browser itself is capable of interpreting WebAssembly code much faster.
The reasons why WebAssembly is so much faster, would boil down to a couple basic reasons:
- WebAssembly is inherently smaller, meaning it can retrieve code over the internet faster
- WebAssembly is already parsed and optimized, so it avoids those steps as well
- WebAssembly is compiled, there is no compilation phase when running the instructions in the browser
- There is no garbage collection within WebAssembly, and it is all managed manually
WebAssembly performs better than JavaScript in many areas. So why hasn't it taken off for many people yet?
Generating WebAssembly
The first step in developing a WebAssembly application is actually finding a way to create it - it is ill advised that you find a 1000-page textbook and start learning how to write WebAssembly yourself. Unless you want to build the next Roller Coaster Tycoon.
WebAssembly is chosen as a target for cross-compilation in a few compilers. It is possible to write JavaScript to target WebAssembly, but JavaScript might not be entirely suitable for handling memory manually.
Instead, we often look to things like Rust or TypeScript to target WebAssembly. Rust has many language facilities which suit WebAssembly perfectly, namely that the Rust compiler strongly enforces memory correctness and prevents many spatial and temporal memory exploits through the syntax alone.
However, I am not super interested in Rust, it's a little too complex for my liking. Rust is a language that's broader than C++ and packs too many hammers into one toolbox. Abstract data types, macros, pattern matching, lifetime elision, traits, ownership and borrowing, asynchronous programming - enough topics to fill out two semesters of computer science courses.
A simpler language exists, named Zig. Zig is a newish language that still hasn't hit a 1.0 milestone release quite yet, it's currently at 0.9.1. But Zig is, in my eyes, a very good language. It's plain, it's simple, and you can read through the documentation pretty easily. It lends itself to classic C, and borrows ideas from other languages in a few places.
The reason why I also don't like Rust as much, is that it heavily depends on generating WASM bindings for you through a package called wasm_bindgen
. This is again, a place where the magic just "happens" in the Rust background and you have to be okay with that and assume it's always correct.
The beauty behind Zig is that it supports many cross-platform targets by using shims from LLVM, and almost any platform that LLVM supports, Zig will support. Let's try to write some Zig code now to add two signed integer numbers.
// add_two.zig
pub fn add_two(a: i32, b: i32) i32 {
return a + b;
}
This is what it's like to write Zig, and it looks vaguely similar to that of Rust with the type annotations. A pub
keyword tells Zig to expose this function to the rest of the Zig program space that you write code in.
For when we want to write Zig code to be turned into a WASM program, we build it as a library, which is part of the command set for compiling Zig code via build-lib
.
$ zig build-lib add_two.zig -target wasm32-freestanding -dynamic
That command will create for you a WASM file that we can use in a browser. We can verify the size of it to be certain.
$ ls -alh add_two.wasm
-rwxr-xr-x 1 steve steve 42 Aug 4 16:47 add_two.wasm*
^- size is 42 bytes
Wait... Forty-two bytes?! That seems a little small. Did we do something wrong? There's no way a WASM can be that small. Well, unfortunately we wrote Zig the wrong way, but that's okay, we're learning.
Re-write our code, except instead of using pub
, we are going to use export
.
// add_two.zig
export fn add(a: i32, b: i32) i32 {
return a + b;
}
Compile it again with:
$ zig build-lib add_two.zig -target wasm32-freestanding -dynamic
Verify it's contents with:
$ ls -alh add_two.wasm
-rwxr-xr-x 1 steve steve 329 Aug 4 16:49 add_two.wasm*
Much better, 329 bytes! The reason why we needed to use export
is to tell Zig we are looking to create what is effectively a shared library object, which exports definitions and code. Before, we never indicated we were trying to create a library, so Zig saw no exportable bindings and exported a WASM file devoid of any meaning.
Eventually, it'd be nice to be able to build a Zig project without needing to write out the zig
command yourself. I will probably cover that later on, but for now it helps to understand what certain flags are.
Importing the WASM
Now here's the section worth going over. We have a blob, but how do we bring it into our web page and get it running and all that?
The first step is that we have to somehow bring in the code altogether. This can be done via one of two ways: either by a network request, or by converting it to a base-64 format and embedding it in your web page. Both have their advantages and disadvantages, but I will use the network method first, as that will be the more common-case.
Back in the day, JavaScript got a feature called XMLHttpRequest
, which is the ability to perform a network call from your current web page and arbitrarily retrieve or execute some function remotely. This was the turning point for most, if not all, modern websites currently. XMLHttpRequest
was the king for a time, until newer methods started surfacing much later.
The new Fetch API is a common browser implementation to be smarter than XMLHttpRequest
and be more elegant, but I'm a bit of an oldie. XMLHttpRequest
is simple, so I'm going to stick with that right now.
First, edit your web page to look like the following:
<!doctype html>
<html>
<head>
<title>WASM Demo</title>
</head>
<body>
<h3>WASM Demo</h3>
</body>
<script src="loader.js"></script>
</html>
Then we need to edit loader.js
next. We put the <script>
tag after all the other HTML in the page, because in JavaScript terms, it's easier to work with DOM elements if they actually exist, instead of depending on waiting for their existence to be defined first. It's less annoying this way. But you can put it wherever you want depending on your application.
Inside loader.js
, we need to load our WASM blob through a special initialization process through the WebAssembly
module called initialize
. This is the main process for loading WASM, although a newer function exists to support a streaming buffer, but we're gonna go classic right now.
// loader.js
request = new XMLHttpRequest();
request.open('GET', 'add_two.wasm');
request.responseType = 'arraybuffer';
request.send();
request.onload = function() {
var bytes = request.response;
WebAssembly.instantiate(bytes, {
env: {}
}).then(result => {
// do wasm things here!
var add_two = result.instance.exports.add_two;
console.log(add_two(3, 5));
});
};
Voila! WASM code loaded into our website and JavaScript is able to call it.
The instantiate
function is the way to take in a byte array representing our WASM code, which we requested in as an array on line 3. The follow-up is that there's an event we define to run after the WASM code has been successfully loaded, which is the .then()
function. This is where we should be operating, as this code will only run when the WASM is fully ready to use.
The issue is you need a running HTTP server to serve this. Most browsers have strict CORS settings that forbid you from loading files locally, so we need to spin up a quick HTTP server. If you have Python installed, you can do the following and navigate to 0.0.0.0:8000
and find your index file wherever you placed it.
$ python -m http.server
With all that, we now have a working WASM application. Well, if you could call adding two numbers a working application.
Importing JavaScript Functions
When we write code in Zig, we have limited access to functionality that only exists elsewhere in the browser. For example, we can't use the Canvas API or WebAudio API. Zig has no bearing on what these APIs are, and without some kind of Zig code to back these APIs, our Zig compiler is to be left in the dust.
However, all is not lost. Let's start with the real question: how do we print text from inside Zig?
The traditional means of printing text in Zig is by using the std.io
library, but that library needs some kind of output stream to write text to, to which we don't really have that. Instead it might be better to import a reference to the JavaScript namespace by using the env
field of the instantiate
function used to start the WASM code.
That env
field can be used to bring in external references into Zig (or whatever your language is), so you can you JavaScript native functions in your Zig program.
First let's modify add_two.zig
.
// add_two.zig
extern fn print(a: i32);
export fn add(a: i32, b: i32) i32 {
print(a + b); // try it here
return a + b;
}
Here we make use of the extern
keyword to refer to a function from outside our program scope, which is to be brought in by the Zig process (or some other process). Usually this is important when targeting C libraries that there is no native Zig code for, but we can still use based on it's object file during the build phase.
Now we have to bring in a reference via the env
section to some kind of printer function. We could import console.log
, but I'll create a wrapper just in case.
// loader.js
request = new XMLHttpRequest();
request.open('GET', 'life.wasm');
request.responseType = 'arraybuffer';
request.send();
request.onload = function() {
var bytes = request.response;
WebAssembly.instantiate(bytes, {
env: {
print: function(x) { console.log(x); }
}
}).then(result => {
// do wasm things here!
var add_two = result.instance.exports.add_two;
console.log(add_two(3, 5));
});
};
It doesn't hurt to always bring in some kind of print function like this for further debugging. Once your code turns into a WASM file, the browser debugger will only get you so far before you start running into logic issues or whatever else comes up. Bring in a print function for your own sanity.
Release Modes
The last part I will go over now is the release modes available in Zig. For almost all compilers, there are different build modes you use to release your project. Some of my smaller WASM projects ended up compiling to thousands of bytes, which ordinarily I wouldn't complain about, because that's still pretty good on average.
However, I wasn't using the right release mode for this. Zig has several flags you can put into the compiler to optimize your release binary.
- Debug, fast compile, safety checks on, slow runtime performance, very large size
- ReleaseFast, fast performance, safety checks disabled, slow compilation, large size
- ReleaseSafe, medium runtime performance, safety checks enabled, slow compilation, large size
- ReleaseSmall, medium performance, safety off, small binary output, smallest size
The debug mode is perfect for when you're still prototyping, but for releasing, you would probably want one of the three after that. ReleaseSmall is very tempting, but if you want some good performance, you might want to consider ReleaseFast instead. In safety critical applications, you should probably aim for using only ReleaseSafe.
In one of my other small demos making a Mandelbrot Set generator, I build the Zig file with a normal build-lib
command.
$ zig build-lib mandelbrot.zig -target wasm32-freestanding -dynamic
It's original size comes out to:
$ ls -alh mandelbrot.wasm
-rwxr-xr-x 1 steve steve 915 Aug 5 10:22 mandelbrot.wasm*
Not bad, but then after doing ReleaseFast:
$ zig build-lib mandelbrot.zig -target wasm32-freestanding -dynamic -O ReleaseFast
$ ls -alh mandelbrot.wasm
-rwxr-xr-x 1 steve steve 232 Aug 5 10:25 mandelbrot.wasm*
It comes out to a whopping 232 bytes. Pretty cool!
In the next post, I will go over some more advanced topics using Zig with WASM, introduce more Zig features like enums or structs, and try to create more programs using Zig and WASM together.
It is my hopes to shed some more light on Zig and WASM, as I find the current resources to be a little scarce. I have an interest in this area and I'm having fun exploring it currently with Zig.
Thanks for reading!
(Warnings: Zig is still in it's beta stages, so things may not be the same in the future. It's still fun none the less.)
Top comments (4)
Thank you for that. Waiting for part 2.
I see there is new method instantiateStreaming. So now it could be like this:
Great article! This got me up and running very quickly. I ran into an issue where
result.instance.exports.add_two
was undefined and I wanted to see if you've come across this solution that worked for me.I needed to add
-rdynamic
[source] flag to the compile command and that made it work for me.I also noticed that
-O ReleaseSmall
made my binary super tiny!!Hey, sorry for the delay, I basically don't log into dev.to anymore at all and prefer using blogging/mastodon/etc now.
Based on my last bits of developing with Zig, I used this command to build out my Zig programs into WASM blobs.
zig build-lib <file> -target wasm32-freestanding -dynamic -rdynamic -O ReleaseSmall
The use of
-dynamic
and-rdynamic
is used, yes, because we're building what's called a freestanding blob where some symbols may not be known at compile-time, so some dynamicism is needed in order to successfully build. I don't keep these posts updated necessarily due to IRL matters, but you are correct in requiring dynamic compilation.I keep all my Zig projects over at github.com/sleibrock/zigtoys, but I have functionally stopped developing with Zig due to a concern with the removal of LLVM from the Zig project.
Thanks for reading!
I made a tool to automatically generate TypeScript bindings for calling Zig compiled to Wasm or native code:
github.com/nelipuu/zbind
It makes conversions easy between types like Zig slices and JS strings, and there's a build script handling different targets and supporting adding external libraries.