til

Shrinking output from Go WebAssembly

Cross-compiling Go code to WebAssembly (WASM) is really powerful. It also produces massive files to download. So how can we shrink this down?
Calcdown web app powered by Go WebAssembly

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 calculations’ 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).

Step 1: Just Get it Working #

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 module
	cmd := exec.Command("go", "build", "-o", outputWasmPath)
	cmd.Dir = wasmDir
	cmd.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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
% ./calcmark wasm
Building WASM module...
  Version: 0.1.23
  Source:  /Users/bitsbyme/projects/go-calcmark/impl/wasm
  Build:   /Users/bitsbyme/projects/go-calcmark/impl/wasm/calcmark-0.1.23.wasm
✓ WASM module built successfully
Copying WASM module...
  From: /Users/bitsbyme/projects/go-calcmark/impl/wasm/calcmark-0.1.23.wasm
  To:   calcmark-0.1.23.wasm
✓ WASM module copied successfully
Copying wasm_exec.js...
  From: /Users/bitsbyme/.asdf/installs/golang/1.24.4/go/lib/wasm/wasm_exec.js
  To:   wasm_exec.js
✓ wasm_exec.js copied successfully

WASM build complete!
  Output files:
    calcmark-0.1.23.wasm
    wasm_exec.js

Step 2: Compress It #

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// 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 download 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:

Step 3: TinyGo #

I just couldn’t stop there though. As I was investigating 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.

Get the Tools #

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)

Build and fail #

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.

Validate the TinyGo WASM workers #

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

One Last Test #

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.

test-tinygo.html

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
<!doctype html>
<html>
    <head>
        <meta charset="utf-8" />
        <title>CalcMark TinyGo WASM Test</title>
        <script src="wasm_exec_tiny.js"></script>
        <script>
            async function loadCalcMark() {
                const go = new Go();

                try {
                    const result = await WebAssembly.instantiateStreaming(
                        fetch("calcmark-0.1.23-tiny.wasm"),
                        go.importObject,
                    );

                    go.run(result.instance);

                    // Test the API
                    console.log("✓ CalcMark loaded successfully");
                    console.log("Version:", window.calcmark.getVersion());

                    // Test evaluation
                    const evalResult = window.calcmark.evaluate(
                        "x = 5 + 3\ny = x * 2",
                        false,
                    );
                    console.log("Evaluation result:", evalResult);

                    if (!evalResult.error) {
                        const results = JSON.parse(evalResult.results);
                        console.log("Parsed results:", results);
                        document.getElementById("output").innerHTML +=
                            `<p><strong>✓ Evaluation test passed</strong></p>
             <pre>${JSON.stringify(results, null, 2)}</pre>`;
                    } else {
                        document.getElementById("output").innerHTML +=
                            `<p style="color: red;"><strong>✗ Evaluation failed:</strong> ${evalResult.error}</p>`;
                    }

                    // Test tokenization
                    const tokenResult =
                        window.calcmark.tokenize("salary = $50000");
                    console.log("Tokenization result:", tokenResult);

                    if (!tokenResult.error) {
                        const tokens = JSON.parse(tokenResult.tokens);
                        document.getElementById("output").innerHTML +=
                            `<p><strong>✓ Tokenization test passed</strong></p>
             <pre>${JSON.stringify(tokens, null, 2)}</pre>`;
                    }

                    // Test validation
                    const validateResult =
                        window.calcmark.validate("x = 5\ny = z + 1");
                    console.log("Validation result:", validateResult);

                    if (!validateResult.error) {
                        const diagnostics = JSON.parse(
                            validateResult.diagnostics,
                        );
                        document.getElementById("output").innerHTML +=
                            `<p><strong>✓ Validation test passed</strong></p>
             <pre>${JSON.stringify(diagnostics, null, 2)}</pre>`;
                    }
                } catch (err) {
                    console.error("Failed to load WASM:", err);
                    document.getElementById("output").innerHTML =
                        `<p style="color: red;"><strong>✗ Failed to load WASM:</strong> ${err.message}</p>`;
                }
            }

            window.addEventListener("load", loadCalcMark);
        </script>
        <style>
            body {
                font-family:
                    -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
                    sans-serif;
                max-width: 800px;
                margin: 50px auto;
                padding: 20px;
            }
            h1 {
                color: #333;
            }
            pre {
                background: #f5f5f5;
                padding: 15px;
                border-radius: 5px;
                overflow-x: auto;
            }
            .info {
                background: #e3f2fd;
                padding: 15px;
                border-radius: 5px;
                margin-bottom: 20px;
            }
        </style>
    </head>
    <body>
        <h1>CalcMark TinyGo WASM Test</h1>

        <div class="info">
            <p><strong>File sizes:</strong></p>
            <ul>
                <li>calcmark-0.1.23-tiny.wasm: 655 KB (TinyGo build)</li>
                <li>wasm_exec_tiny.js: 16 KB</li>
            </ul>
            <p><strong>Compare to standard Go build:</strong></p>
            <ul>
                <li>calcmark-0.1.23.wasm: 3.7 MB (5.6x larger!)</li>
            </ul>
        </div>

        <div id="output">
            <p>Loading CalcMark WASM... (check browser console for details)</p>
        </div>
    </body>
</html>

See Also

View page source