Shrink Your TinyGo WebAssembly Modules by 60%

With WebAssembly, small binary sizes are good. We at Fermyon like the idea of small microservices for the cloud, but even if you are building for the browser, shaving off a few kilobytes of data can speed up your app.

In this post, we cover a few ways to shrink down those Go binaries when compiling to WebAssembly with TinyGo.

A Starting Point

Not long ago, I wrote about Writing a WebAssembly Service in TinyGo for Wagi and Spin. In that article, we created a small microservice in Go that used Spin and served out a website favicon. You can take a look at that blog post if you’d like to see the code. But for now, let’s look at how we compiled that code:

$ tinygo build -o favicon.wasm -target wasi main.go

As discussed in that post, the TinyGo compiler is able to compile most Go code to Wasm32+WASI (while Go’s standard compiler does not support the WASI extensions, and builds browser-centric code).

The output of the above command is a Wasm file named favicon.wasm:

$ ls -lah *.wasm               
-rwxr-xr-x  1 technosophos  staff   1.1M May  3 16:33 favicon.wasm

Now, 1.1M is certainly not a huge file size, but we can do some things to reduce it. We’ll start by taking a look at ways to reduce size at compile time using TinyGo. Then we will look at an extra tool (Binaryen’s wasm-opt) that optimizes WebAssembly binaries regardless of the language in which they were written.

One thing we will not cover here is code-level optimizations. For example, not using the fmt package can shrink down the binary size. But it comes at the cost of functionality and possibly readability. We at Fermyon tend not to try to cut code weight this way unless we are really trying to optimize.

Cutting Size at Compile Time

One way to optimize binary sizes is by having the compiler perform some optimizations. After I wrote the TinyGo Favicon article, Ayke van Laethem, one of the TinyGo maintainers, reached out to me with some information. He suggested a few flags to optimize Go Wasm binaries at compile time.

The first compiler flag is the most important. Adding it will drastically reduce your Wasm sizes. The flag -no-debug will strip out all of the debugging symbols. For those from a Rust background, my understanding is that this is similar to running cargo build --release (which also makes a massive difference in Wasm file sizes).

$ tinygo build -o favicon.wasm -target=wasi -wasm-abi=generic -gc=leaking -no-debug main.go
$ ls -lah favicon.wasm
-rwxr-xr-x  1 technosophos  staff   396K May  3 16:36 favicon.wasm

Because of one flag (-no-debug) our binary size dropped 700k! It is important to note, though, that the price of this reduction is that the resulting binary will not work well with a debugger. For production, that’s normally a great trade-off.

The -gc=leaking flag doesn’t necessarily optimize the size, but it does instruct TinyGo to use a minimal garbage collection algorithm that (as the name implies) leaks memory. Because Spin apps run for only milliseconds, a leaky garbage collector is fine, and will make the program run faster.

Another flag to experiment with is the -opt flag. It takes the digits 0, 1, 2 or the letter z. The default is z, which optimizes for size (and so is already evident in the code above.)

According to Ayke, using -opt=2 is a good tradeoff between speed and size. For us, using this flag netted us a slightly larger binary. But if it performs better on Spin, that still may be worth it:

$ tinygo build -o favicon.wasm -target=wasi -wasm-abi=generic -gc=leaking -no-debug -opt=2 main.go
$ ls -lah favicon.wasm
-rwxr-xr-x  1 technosophos  staff   503K May  3 16:48 favicon.wasm

We will be adding -no-debug to our standard release process to take advantage of the size reduction. As for -opt=2, we’ll continue evaluating its performance.

Reducing More with Wasm-Opt

The second tool to look at is wasm-opt, which is part of the Binaryen project. The wasm-opt tool reads a WebAssembly file, optimizes it, and then produces a new WebAssembly file. Since it operates on the .wasm file directly, it can be used regardless of programming language.

For some languages, using wasm-opt can achieve astounding results. For example, it can cut Swift binary sizes down by 4M or more. Even with our optimized Go binary we can see some modest results.

Here we’ll run our compile and then pass it through wasm-opt:

$ tinygo build -o favicon.wasm -target=wasi -wasm-abi=generic -gc=leaking -no-debug main.go
$ ls -lah favicon.wasm
-rwxr-xr-x  1 technosophos  staff   396K May  3 16:51 favicon.wasm
$ wasm-opt -O favicon.wasm -o favicon.wasm
$ ls -lah favicon.wasm
-rwxr-xr-x  1 technosophos  staff   377K May  3 16:51 favicon.wasm

For wasm-opt, we used the -O flag to get the default optimizations. That’s another 19k shaved off the binary size. As with TinyGo’s -opt flag, wasm-opt’s -O argument can take further optimization info. The digits 1 to 4 execute one to four optimization passes. These passes can produce larger binaries that perform better, though in our experiments they usually shrink the binary size as well. The characters s and z optimize for file size, with z being the most aggressive.

For our small program, -O, -Os, and -Oz all produced a binary of 377k. But in your application you may want to try each and see what produces the best outcome.

The wasm-opt tool has dozens of other options that can be used to control very specific optimizations. If you are interested in micro-tuning, you may enjoy taking a deep dive into wasm-opt --help.

Conclusion

We started with a 1.1M Wasm file from a Go source program. And we ended with a 377k version of the exact same source file. These tools are a great way of shrinking down the binary files without having to re-code your application.

Special thanks to Ayke van Laethem for pointing us to the relevant TinyGo flags.

If you’d like to chat with us more about optimizing WebAssembly code in Go or any other language, join us on Discord or message us on Twitter.

Interested in learning more?

Get Updates