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
The project goals are:
- Easy to write, human readable DSL.
- Generate a printable, server generated HTML page for every song.
- Keep JS dependencies to the absolute minimum in the rendered HTML song files. 1
- Implement an offline, in-browser, live editor to parse and render the DSL code in real time, to be used by people not comfortable with the command line.
Here’s a diagram showing the different artifact types involved in this project and an overview of how they’re built:

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
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:
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:
- It reduced the runtime (in the browser) from an average of 32ms, to 23ms as you can see in the logs already posted.
- The WASM binary compiled with the standard Go compiler shrunk from 9.1MB to 6.16MB
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
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
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
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
- Go’s WASM support is OK and doable, but has room for improvement in binary size optimization. If you have an application already written in Go, a strong team of Go engineers, and a use case for WASM, it’s an option worth considering. But if you’re writing something from scratch with a WASM requirement, a different language might yield better results as of today.
- Standard Go compiler targeting WASM produced nearly 7x larger binaries than TinyGo for this project. But TinyGo’s stdlib support is limited, restricting your library choices. Check the TinyGo support for stdlib and the libraries that you’re planning to use.
- TinyGo doesn’t benefit from Binaryen’s wasm-opt optimizations. In fact, with my use case, wasm-opt produced larger binaries than plain TinyGo.
- WASM binaries compress really well with gzip, and a little bit better with brotli (gzipped version being 33% of initial WASM size).
- Using
html/templatesignificantly increases binary size.Templmight be a better choice (and it has a nicer syntax and good IDE/editor support). - Side note: Wazero (which is used by fastchema/qjs under the hood) can be slower than other WASM runtimes according to this page, but it’s very convenient to use (it doesn’t depend on CGO, so easier builds, portability, etc).
Additional resources
- https://dev.to/ergofriend/gos-template-engine-templ-is-convenient-it-also-works-with-tinygo-3ad6
- https://tinygo.org/docs/reference/lang-support/stdlib/
- https://dagger.io/blog/replaced-react-with-go
- https://templ.guide/
- https://chiselapp.com/user/moinejf/repository/abc2svg/doc/trunk/README.md
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. ↩︎
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. ↩︎