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?

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

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 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

Step 3: TinyGo #

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.

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