Cross-compiling Go code to WebAssembly (WASM) is really powerful.
It also produces massive files to download.
So how can we shrink this down?
For go-calcmark I decided that I would keep an implementation of the spec in the same Git repository as the spec itself.
In other words, I would write the reference specification for my little ‘Markdown plus calcluations’ grammar in Go.
Crucially, I didn’t want to then have to re-write an implementation to power my web editor calcdown.app (source) in TypeScript.
I decided instead to use the built-in cross-compilation of the Go language to WebAssembly (WASM).
The Go tooling is rock solid.
For every tagged release I push to GitHub, there’s a corresponding release that automatically includes the appropriate WASM binary (example of 0.1.23 release).
This is mostly just a case of using the GOOS=js and GOARCH=wasm environment variables to inform the Go compiler (source)
1
2
3
4
5
6
7
// Build the WASM modulecmd:=exec.Command("go","build","-o",outputWasmPath)cmd.Dir=wasmDircmd.Env=append(os.Environ(),"GOOS=js","GOARCH=wasm",)
The output from the calcmark wasm tool tells the story, and this is exactly what is being used in the CI / release creation process:
Do you notice just how big the WASM file is?
Almost 4MB!
Now that’s trivial for a server or desktop app, but as a runtime dependency for a web app it’s waaaaaaaay too big.
My first thought was that compression could help.
Good old gzip and a newer algorithm that I hadn’t come across before: Brotli.
So in my web app, I created some scripts specifically to download the correct WASM file from the go-calcmark repo, then compress those files as part of the build process prior to pushing to production.
1
2
3
4
"wasm:fetch":"node scripts/download-wasm.js","wasm:clean":"node scripts/download-wasm.js --clean","wasm:verify":"node scripts/verify-compression.js","postinstall":"npm run wasm:fetch",
The real work is being done by the incredible vite-plugin-compression2 plugin for the even more incredible Vite development server and build tool:
// Brotli compression for WASM files (better compression)
compression({algorithm:'brotliCompress',include:/\.(wasm)$/,threshold: 1024,deleteOriginalAssets: false}),// Gzip compression for WASM files (broader compatibility)
compression({algorithm:'gzip',include:/\.(wasm)$/,threshold: 1024,deleteOriginalAssets: false})],assetsInclude:['**/*.wasm'],// Ensure WASM files are treated as external assets in SSR
ssr:{noExternal:[]},
So npm run build does this for us, among other things:
1
2
3
4
5
6
7
8
9
> node scripts/download-wasm.js
📦 CalcMark WASM Download Script
📌 Target version: v0.1.23
📦 Downloaded version: v0.1.23
✅ WASM files already exist for this version, skipping download
Current: v0.1.23
To force re-download, run: npm run wasm:clean
The theory is that by shipping not just the calcmark.wasm, but also pre-compressed gzip and brotli versions of that file, clients would have to downalod a lot less data.
But how much less?
A lot, and this is basically free because (pretty much) every browser supports both gzip and brotli compression.
Yep: down from a whopping 3.8M to 814K.
1
2
3
4
5
% ls -lh client/_app/immutable/workers/assets
total 11648-rw-r--r--@ 1 bitsbyme staff 3.8M Nov 17 22:14 calcmark-DO9gXOVC.wasm
-rw-r--r--@ 1 bitsbyme staff 814K Nov 17 22:14 calcmark-DO9gXOVC.wasm.br
-rw-r--r--@ 1 bitsbyme staff 1.1M Nov 17 22:14 calcmark-DO9gXOVC.wasm.gz
This is what is used in the live calcdown.app for all of the syntax highlighting and evaluation of the actual calculations:
The web based caldown app showing syntax highlighting and live calculation evaluation using the Go WASM library
I just couldn’t stop there though.
As I was investigation cross-compilation to WASM in go, I came across TinyGo.
This is an alternative to the Go compiler that can target WASM, and ‘Small Places’ in general.
So I thought: could it do better?
My go-calcmark implementation doesn’t do anything fancy with concurrency so it’s a good candidate for the simple expectations that TinyGo can meet.
This took some trial and error and I have not yet built it into my release pipeline because I’m not sure it’s worth the effort yet.
I use asdf to manage all of my runtimes and luckily there’s a plugin for TinyGo distinct from Go.
1
2
3
4
5
6
7
8
$ asdf plugin add tinygo https://github.com/troyjfarrell/asdf-tinygo
$ asdf install tinygo latest
* Downloading tinygo release 0.39.0...
tinygo0.39.0.darwin-arm64.tar.gz: OK
tinygo 0.39.0 installation was successful!
$ asdf set -u tinygo latest
$ tinygo version
tinygo version 0.39.0 darwin/arm64 (using go version go1.24.4 and LLVM version 19.1.2)
The build command was pretty much the same as before.
1
2
3
4
5
6
7
8
9
GOOS=js GOARCH=wasm tinygo build -o my.wasm ./impl/wasm
go: downloading golang.org/x/text v0.30.0
go: downloading github.com/shopspring/decimal v1.4.0
error: could not find wasm-opt, set the WASMOPT environment variable to override
# A quick search recommended using the --no-debug flag$ GOOS=js GOARCH=wasm tinygo build -o my.wasm --no-debug ./impl/wasm
error: could not find wasm-opt, set the WASMOPT environment variable to override
[⎇ demo-cleanup]% brew install binaryen
Turns out that the TinyGo WASM optimizer itself depends on on the Binaryen WASM toolchain.
So let’s install that then try again:
1
2
$ brew install binaryen
...stuff...
And after a bit more research, I wound up with this beauty.
Note the -target=wasm instead of the GOARCH=wasm
1
2
3
$ tinygo build -target=wasm -no-debug -o calcmark-0.1.23-tiny.wasm./impl/wasm/main.go
$ ls -lh calcmark-0.1.23-tiny.wasm
-rw-r--r--@ 1 bitsbyme staff 655K Nov 17 13:52 calcmark-0.1.23-tiny.wasm
Wow! Now we’re down to just 655K instead of nearly 4MB.
This is progress.
I had an AI agent spin up a little 1 page test to validate that the TinyGo WASM would work as I expect it to.
Honestly, I was expected to discover some weird edge cases but no: it worked perfectly.
TinyGo WASM test page showing that key functions for the calcmark parser, tokenizer, and evaluator work perfectly in 655 kilobytes
I haven’t decided yet whether I want to depend on TinyGo or not.
This entire project is getting sidetracked by my learning curve and I need to build more features into the basic CalcDown editor (like, maybe saving stuff!).
But the final thing I did to see how far I could take it:
1
2
3
4
$ brotli calcmark-0.1.23-tiny.wasm
$ ls -lh calcmark-0.1.23-tiny.wasm*
-rw-r--r--@ 1 bitsbyme staff 655K Nov 17 13:52 calcmark-0.1.23-tiny.wasm
-rw-r--r--@ 1 bitsbyme staff 192K Nov 17 13:52 calcmark-0.1.23-tiny.wasm.br
192K for a TypeScript and JavaScript compatible version of a Go library that can be used in a web browser.
Technology is truly a wonderful thing.
1
2
3
4
5
6
7
8
9
Fresh builds created:
- ✅ calcmark-0.1.23.wasm (3.7
MB) - Standard Go build
- ✅ calcmark-0.1.23-tiny.wasm
(655 KB) - TinyGo build
- ✅ wasm_exec.js (17 KB) -
Standard Go glue
- ✅ wasm_exec_tiny.js (16 KB) -
TinyGo glue
I’ll leave the entire HTML page here because it’s instructive on how to load and consume WASM created by a Go library using either the Go or the TinyGo compilers.
Line 24 is where the ’native’ code is called.