Go and wasm adventures

Nov 24, 2025 · 10 minutes read

Table of contents

Introduction

I’ve been tinkering with Go targeting WASM recently. This post is a summary of my observations and lessons learned during this journey.

The excuse to mess with this technology has been a project that I’ve developed called Lesheets: a tool to write music chord charts from a plain text format.

Lesheets overview

In a nutshell, it’s a CLI tool and a live editor that transforms this:

---
title: Autumn Leaves
tempo: 140bpm
key: Em
columns: 2
---

# A

||: Am7 | D7 | Gmaj7 | Cmaj7 |
    F#halfdim7 | B7 | Em | % :||

# B

F#halfdim7 | B7b9 | Em | % |
Am7 | D7 | Gmaj7 | % |

#- C

F#halfdim7 | B7b9 | Em Eb7 | Dm7 Db7 |
Cmaj7 | B7b9 | Em  |  % |

into this:

Autumn Leaves chord chart

Autumn Leaves chord chart

The project goals are:

Here’s a diagram showing the different artifact types involved in this project and an overview of how they’re built:

Artifcats build overview
Artifacts build overview

More info about the project in its README.

WebAssembly Fun: Executing Go from JS, and JS from Go 🤯

First of all, why? Well, basically because I implemented the parsing and lexing logic in Go without thinking too much about which technology to use. After deciding that I wanted to add ABC music notation support, I didn’t find any implementation written in Go. The ones that seemed to fit better were written in JS. And exploring WASM sounded way more fun than rewriting it in JS, so the rabbit hole began…

JS from GO: fastschema/qjs

Among JS libraries, Abcjs looked very promising: it appeared active, modern, with good documentation and a good number of people using it. I wrote a PoC with it. But then I realized that it required a DOM, and I really wanted to produce plain HTML with no JS dependencies if possible.

I continued researching and found Abc2svg. It looked like a good fit to render the SVG in the server and remove any JS dependency from the final HTML.

Buuut, wait 🤔… How am I going to call this library from Go in the server? After thinking about it, I stumbled upon fastschema/qjs:

A CGO-Free, modern, secure JavaScript runtime for Go applications, built on the powerful QuickJS engine and Wazero WebAssembly runtime.

This looks amazing! Let’s give it a try.

After a day or two mostly looking at the Abc2svg JS code (with its very particular build system based on ninja, code style and even version control system) trying to figure out how to instantiate and call the library to get the precious SVG back, I finally managed to do it! My Go application was rendering HTML with SVG embedded directly. Ohh yes! 🙌

After reusing the QJS engine instance across renders (by eagerly instantiating the engine, preloading the JS files and caching ABC code snippets that were previously rendered), I had a version that was rendering charts in 30ms-70ms (spoiler: final version renders in 1-20ms).

These are the logs of a song with no ABC snippets (nashville.lesheet):

2025/11/21 18:29:43 Timer:RenderHtml:36.000000ms editor.js:31338:19
2025/11/21 18:29:44 Timer:RenderHtml:34.000128ms editor.js:31338:19
2025/11/21 18:29:45 Timer:RenderHtml:21.999872ms editor.js:31338:19
2025/11/21 18:29:46 Timer:RenderHtml:29.999872ms editor.js:31338:19
2025/11/21 18:29:46 Timer:RenderHtml:28.000256ms editor.js:31338:19
2025/11/21 18:29:47 Timer:RenderHtml:34.000128ms editor.js:31338:19
2025/11/21 18:29:47 Timer:RenderHtml:29.999872ms editor.js:31338:19
2025/11/21 18:29:48 Timer:RenderHtml:36.000000ms editor.js:31338:19
2025/11/21 18:29:48 Timer:RenderHtml:38.000128ms editor.js:31338:19
2025/11/21 18:29:49 Timer:RenderHtml:35.000064ms editor.js:31338:19
2025/11/21 18:29:49 Timer:RenderHtml:18.999808ms editor.js:31338:19
2025/11/21 18:29:50 Timer:RenderHtml:32.999936ms editor.js:31338:19
2025/11/21 18:29:50 Timer:RenderHtml:32.999936ms editor.js:31338:19
2025/11/21 18:29:50 Timer:RenderHtml:28.000000ms editor.js:31338:19
2025/11/21 18:29:51 Timer:RenderHtml:36.999936ms editor.js:31338:19
2025/11/21 18:29:52 Timer:RenderHtml:24.999936ms editor.js:31338:19
2025/11/21 18:29:52 Timer:RenderHtml:30.000128ms editor.js:31338:19
2025/11/21 18:29:53 Timer:RenderHtml:27.000064ms editor.js:31338:19
2025/11/21 18:29:53 Timer:RenderHtml:40.000000ms editor.js:31338:19
2025/11/21 18:29:54 Timer:RenderHtml:27.000064ms editor.js:31338:19
2025/11/21 18:29:54 Timer:RenderHtml:25.999872ms editor.js:31338:19
2025/11/21 18:29:55 Timer:RenderHtml:35.000064ms editor.js:31338:19
Native Go rendering times with html/template

For songs with many ABC snippets, I’ve seen that the rendering could take up to 70ms.

Executing Go in the browser: Offline live editor in the browser

Cool! I now had a CLI app that produced static HTML from my DSL, so it was time to think about how to share this software with people who aren’t comfortable with the command line. So, the in-browser live editor requirement emerged. While I could expose the Go application as an HTTP server, and make requests from the client, that didn’t seem cool enough. The client side parsing and rendering idea was not something I wanted to abandon quickly. At this point I wondered why I chose Go instead of JS/TS to write the parser…

But, hey, Go can target WASM, right? That should make it! The first logic step was trying to compile my app with TinyGo targeting WASM, since it produces lighter binaries. It compiled! However, the excitement didn’t last long. This lovely error appeared in the browser console when trying to execute the code:

panic: unimplemented: (reflect.Type).NumOut()

The TinyGo html/template support documentation showed that html/template is not supported by TinyGo (at the time of writing).

Okay, no problem, let’s stick with the standard Go compiler for now. I tried compiling the existing code, and…it worked… It just worked! I was able to pass the contents of a textarea to WASM, and get the rendered html back. So, I took a moment to celebrate this milestone.

After the celebration, I checked the WASM size… And:

$ du -sh build/wasm.wasm
9.3M    build/wasm.wasm

Oh my god! That’s huge! 2

I tried stripping debugging symbols using go build -ldflags="-s -w":

$ du -sh build/wasm.wasm
9.1M    build/wasm.wasm

Nah. That doesn’t help. Then I checked if gzip could improve this:

$ du -sh build/wasm.wasm.gz
3.1M    build/wasm.wasm.gz

Hmmm. Oook. That’s better. Still a huge size for what the application is doing, but it’s something. Then, I tried binaryen’s wasm-opt optimizer:

$ wasm-opt output/wasm.wasm -Oz --enable-bulk-memory-opt -o output/optimized.wasm
$ du -sh output/optimized.wasm
8.4M    output/optimized.wasm
$ gzip -9 output/optimized.wasm > output/optimized.wasm.gz
$ du -sh output/optimized.wasm.gz
2.4M    output/optimized.wasm.gz

Ok, that’s even better…

However, I wasn’t entirely satisfied, so I revisited the TinyGo research. Perhaps, using a different templating system I could use TinyGo. I saw this article where they mention that they successfully used templ with TinyGo. So I gave it a try.

…and it worked:

editor-final-sizes 1,36MB and 459kB after compression for the wasm binary. And 750kB (after compressed) for the whole application sounds quite reasonable to me! I was very happy with this result. So, I took another moment to celebrate a bit more 🎊.

Note: Using templ instead html/template had two nice side effects:

Render time comparison between browser and WASM

This is a log of the RenderHtml times by running the CLI application

2025/11/19 23:05:48 Timer:RenderHtml:0.04ms
2025/11/19 23:05:49 Timer:RenderHtml:56.65ms
2025/11/19 23:05:49 Timer:RenderHtml:36.21ms
2025/11/19 23:05:49 Timer:RenderHtml:33.73ms
2025/11/19 23:05:49 Timer:RenderHtml:31.70ms
2025/11/19 23:05:50 Timer:RenderHtml:7.47ms
2025/11/19 23:05:50 Timer:RenderHtml:19.38ms
2025/11/19 23:05:50 Timer:RenderHtml:29.63ms
2025/11/19 23:05:50 Timer:RenderHtml:64.88ms
2025/11/19 23:05:51 Timer:RenderHtml:31.69ms
2025/11/19 23:05:51 Timer:RenderHtml:4.82ms
2025/11/19 23:05:51 Timer:RenderHtml:5.46ms
2025/11/19 23:05:51 Timer:RenderHtml:5.86ms

And SVG rendering times of the song that took 56.65ms above:

2025/11/19 23:05:49 Timer:RenderSvg:6.44ms
2025/11/19 23:05:49 Timer:RenderSvg:4.59ms
2025/11/19 23:05:49 Timer:RenderSvg:0.00ms
2025/11/19 23:05:49 Timer:RenderSvg:4.45ms
2025/11/19 23:05:49 Timer:RenderSvg:0.00ms
2025/11/19 23:05:49 Timer:RenderSvg:4.28ms
2025/11/19 23:05:49 Timer:RenderSvg:4.13ms
2025/11/19 23:05:49 Timer:RenderSvg:3.61ms
2025/11/19 23:05:49 Timer:RenderSvg:3.49ms
2025/11/19 23:05:49 Timer:RenderSvg:0.00ms <--- btw, this is the svg cache doing its magic
2025/11/19 23:05:49 Timer:RenderSvg:0.00ms
2025/11/19 23:05:49 Timer:RenderSvg:0.00ms
2025/11/19 23:05:49 Timer:RenderSvg:2.59ms
2025/11/19 23:05:49 Timer:RenderSvg:0.00ms
2025/11/19 23:05:49 Timer:RenderSvg:0.00ms
2025/11/19 23:05:49 Timer:RenderSvg:3.04ms
2025/11/19 23:05:49 Timer:RenderSvg:3.45ms
2025/11/19 23:05:49 Timer:RenderSvg:3.22ms
2025/11/19 23:05:49 Timer:RenderSvg:2.56ms
2025/11/19 23:05:49 Timer:RenderSvg:2.54ms
2025/11/19 23:05:49 Timer:RenderSvg:2.32ms
2025/11/19 23:05:49 Timer:RenderSvg:2.07ms
2025/11/19 23:05:49 Timer:RenderHtml:56.65ms

Runtime was mainly spent on abc2svg rendering. Adding up all the Svg render times:

$ python3
>>> 6.44+4.59+0.00+4.45+0.00+4.28+4.13+3.61+3.49+0.00+0.00+0.00+2.59+0.00+0.00+3.04+3.45+3.22+2.56+2.54+2.32+2.07
52.78

So the HTML render time for that song is consistent with the previous results (~6ms) if we leave the SVG renders aside.

Since SVG renders are executed sequentially, they could be optimized with parallelization: instantiating a pool of several Javascript runtime instances (QJS supports pooling, so it shouldn’t be too hard to implement this)

2025/11/19 23:45:42 Timer:RenderHtml:5.22ms
2025/11/19 23:45:46 Timer:RenderHtml:3.83ms
2025/11/19 23:45:49 Timer:RenderHtml:4.58ms
2025/11/19 23:46:46 Timer:RenderHtml:7.73ms
2025/11/19 23:46:49 Timer:RenderHtml:8.07ms
2025/11/19 23:46:51 Timer:RenderHtml:6.63ms
2025/11/19 23:46:54 Timer:RenderHtml:6.61ms
Native go render times of a song with no abc2svg snippets

In Zen browser, the same song averaged ~23ms to render:

2025/11/19 23:49:48 Timer:RenderHtml:15.00ms wasm_exec.v-IMMOWV2I.js:259:19
2025/11/19 23:49:50 Timer:RenderHtml:33.00ms wasm_exec.v-IMMOWV2I.js:259:19
2025/11/19 23:49:51 Timer:RenderHtml:14.00ms wasm_exec.v-IMMOWV2I.js:259:19
2025/11/19 23:49:53 Timer:RenderHtml:33.00ms wasm_exec.v-IMMOWV2I.js:259:19
2025/11/19 23:49:53 Timer:RenderHtml:13.00ms wasm_exec.v-IMMOWV2I.js:259:19
2025/11/19 23:49:54 Timer:RenderHtml:28.00ms wasm_exec.v-IMMOWV2I.js:259:19
2025/11/19 23:49:55 Timer:RenderHtml:15.00ms wasm_exec.v-IMMOWV2I.js:259:19
2025/11/19 23:49:56 Timer:RenderHtml:32.00ms wasm_exec.v-IMMOWV2I.js:259:19
2025/11/19 23:49:56 Timer:RenderHtml:12.00ms wasm_exec.v-IMMOWV2I.js:259:19
2025/11/19 23:49:57 Timer:RenderHtml:30.00ms wasm_exec.v-IMMOWV2I.js:259:19
2025/11/19 23:49:57 Timer:RenderHtml:14.00ms wasm_exec.v-IMMOWV2I.js:259:19
2025/11/19 23:49:58 Timer:RenderHtml:33.00ms wasm_exec.v-IMMOWV2I.js:259:19
2025/11/19 23:49:58 Timer:RenderHtml:15.00ms wasm_exec.v-IMMOWV2I.js:259:19
2025/11/19 23:49:59 Timer:RenderHtml:34.00ms wasm_exec.v-IMMOWV2I.js:259:19
Zen Browser render times of a song with no abc2svg snippets

Chrome gave similar results: average of 20ms:

wasm_exec.v-IMMOWV2I.js:259 2025/11/20 19:57:45 Timer:RenderHtml:14.10ms
wasm_exec.v-IMMOWV2I.js:259 2025/11/20 19:57:46 Timer:RenderHtml:24.60ms
wasm_exec.v-IMMOWV2I.js:259 2025/11/20 19:57:47 Timer:RenderHtml:22.80ms
wasm_exec.v-IMMOWV2I.js:259 2025/11/20 19:57:48 Timer:RenderHtml:24.20ms
wasm_exec.v-IMMOWV2I.js:259 2025/11/20 19:57:48 Timer:RenderHtml:17.00ms
wasm_exec.v-IMMOWV2I.js:259 2025/11/20 19:57:49 Timer:RenderHtml:18.80ms
wasm_exec.v-IMMOWV2I.js:259 2025/11/20 19:57:49 Timer:RenderHtml:25.60ms
wasm_exec.v-IMMOWV2I.js:259 2025/11/20 19:57:50 Timer:RenderHtml:16.00ms
wasm_exec.v-IMMOWV2I.js:259 2025/11/20 19:57:50 Timer:RenderHtml:26.50ms
wasm_exec.v-IMMOWV2I.js:259 2025/11/20 19:57:51 Timer:RenderHtml:16.60ms
wasm_exec.v-IMMOWV2I.js:259 2025/11/20 19:57:51 Timer:RenderHtml:27.60ms
wasm_exec.v-IMMOWV2I.js:259 2025/11/20 19:57:52 Timer:RenderHtml:16.40ms
wasm_exec.v-IMMOWV2I.js:259 2025/11/20 19:57:53 Timer:RenderHtml:27.30ms
wasm_exec.v-IMMOWV2I.js:259 2025/11/20 19:57:53 Timer:RenderHtml:16.50ms
wasm_exec.v-IMMOWV2I.js:259 2025/11/20 19:57:54 Timer:RenderHtml:28.70ms
wasm_exec.v-IMMOWV2I.js:259 2025/11/20 19:57:54 Timer:RenderHtml:17.20ms
wasm_exec.v-IMMOWV2I.js:259 2025/11/20 19:57:55 Timer:RenderHtml:26.70ms
wasm_exec.v-IMMOWV2I.js:259 2025/11/20 19:57:55 Timer:RenderHtml:17.10ms
wasm_exec.v-IMMOWV2I.js:259 2025/11/20 19:57:56 Timer:RenderHtml:26.90ms
Chrome Browser render times of a song with no abc2svg snippets

In summary, WASM in the browser performed ~3 times slower compared to native Go for this application. I didn’t expect that much overhead. I’m unsure if this overhead is normal, or I am doing something wrong. I haven’t paid attention to memory allocations in the Go code nor tried to reduce them, so there’s likely optimization potential there. But I’ll leave that investigation branch for another time.

Conclusions

Additional resources


  1. The JavaScript code ended up being 1.01KB, mainly to toggle dark mode, and to allow the user to copy the song code to the clipboard. ↩︎

  2. Go includes its runtime (garbage collector, goroutines scheduler, memory allocator and so on) in the binaries. It also adds the stdlib code that your code is actually using. That makes a minimal hello world binary 2-3MB. TinyGo provides a simpler (and more limited) runtime to produce smaller binaries and makes it able to run Go code even in embedded devices. ↩︎