DEV Community

Christoph Dieck
Christoph Dieck

Posted on

From 7MB to 52KB: My WebAssembly Size Optimization Journey

The problem

I built an HTML-to-Markdown converter that runs entirely in the browser via WebAssembly. No server, no uploads. The conversion engine ships as a .wasm binary that every visitor downloads on first load. That means binary size directly impacts time-to-interactive.

Over three rewrites I went from 7MB down to 52KB. A 99.3% reduction. Here's what each step taught me.

Generation 1: Standard Go (GOOS=js GOARCH=wasm)

The first version used Go with the html-to-markdown library by JohannesKaufmann and golang.org/x/net/html for DOM parsing. Go made it easy to get a working pipeline quickly.

The problem: Go's WASM output includes the entire Go runtime. Goroutine scheduler, garbage collector, and the full reflect package pulled in by encoding/json as a transitive dependency.

GOOS=js GOARCH=wasm go build -o convert.wasm ./wasm/
Enter fullscreen mode Exit fullscreen mode

Result: ~7 MB (uncompressed)

Even with encoding/json stripped and buildvcs=false, the runtime overhead is substantial. Go's WASM target was designed for feature-completeness, not binary size.

Generation 2: TinyGo

TinyGo compiles Go to WASM using LLVM instead of Go's native compiler. It produces dramatically smaller binaries by using a simpler runtime, a different garbage collector, and aggressive dead code elimination.

tinygo build -o convert.wasm -target=wasm -no-debug ./wasm/
Enter fullscreen mode Exit fullscreen mode

Result: ~936 KB (after wasm-opt)

A 7x reduction. TinyGo's LLVM backend and minimal runtime make a massive difference. However, I hit a ceiling. The html-to-markdown library and golang.org/x/net/html parser are non-trivial, and TinyGo's limited reflect support caused compatibility friction.

Generation 3: Rust with scraper + htmd

Next I rewrote the pipeline in Rust using scraper (html5ever-based HTML parser with CSS selectors) and htmd (a turndown.js-inspired Markdown converter). Full DOM tree, density-based content extraction, CSS selector queries.

wasm-pack build --target web --release
Enter fullscreen mode Exit fullscreen mode
[profile.release]
opt-level = 'z'
lto = true
codegen-units = 1
strip = "debuginfo"
panic = "abort"
Enter fullscreen mode Exit fullscreen mode

Result: 936 KB (after wasm-bindgen)

Same as TinyGo. The html5ever parser (Servo's browser-grade HTML engine) is thorough but heavy. It pulls in markup5ever, tendril, phf, string_cache, and about 124 crates total. For a browser-side tool, still too large.

Generation 4: Rust with a custom parser (zero dependencies)

I asked: do I actually need a full DOM tree and CSS selectors?

The answer is no. The pipeline does three things:

  1. Skip noise tags (script, nav, footer, etc.)
  2. Extract the main content region
  3. Map HTML tags to Markdown syntax

None of this requires a tree. A single-pass state machine can do all three in one linear scan. No allocations for a DOM, no selector engine, no string interning.

I dropped every dependency except wasm-bindgen itself and wrote a custom streaming converter:

  • Tag-level state machine (splits on < and >, not a character-level tokenizer)
  • Noise depth counter (increments on <nav>, decrements on </nav>, skips all content when depth > 0)
  • Content extraction via <main> / <article> / <body> substring search
  • Direct tag-to-Markdown emission (no intermediate representation)
[dependencies]
wasm-bindgen = "0.2"

[profile.release]
opt-level = 'z'
lto = true
codegen-units = 1
strip = true
panic = "abort"
Enter fullscreen mode Exit fullscreen mode

Result: 52 KB

The comparison

Approach Binary Size Dependencies Parsing Strategy
Go (standard) ~7 MB html-to-markdown, x/net/html DOM tree
TinyGo ~936 KB html-to-markdown, x/net/html DOM tree
Rust (scraper + htmd) 936 KB 124 crates (html5ever, ego-tree, etc.) DOM tree + CSS selectors
Rust (custom parser) 52 KB 1 crate (wasm-bindgen) Single-pass state machine
Step Size reduction Cumulative from Go
Go to TinyGo 87% 87%
TinyGo to Rust (scraper) 0% 87%
Rust (scraper) to Rust (custom) 94% 99.3%

What I traded away

The custom parser is intentionally less capable than html5ever:

No error recovery for malformed HTML. I split on < and >. Severely broken markup may produce garbled output. In practice, real-world HTML from documentation sites and CMSes is well-formed enough.

No CSS selector queries. No div.content > article. Instead I use simple string-based tag matching for content extraction.

No character encoding detection. I assume UTF-8 (with String::from_utf8_lossy as fallback). This covers 99%+ of modern web content.

Simplified density extraction. Instead of scoring every <div> by text density with heading bonuses, I just grab <main> or <article>. Less precise, but covers the vast majority of documentation and CMS pages.

For my use case. Converting documentation HTML for AI consumption. These tradeoffs are acceptable. The content is typically well-structured and uses semantic HTML.

Key takeaways

The runtime dominates in Go/TinyGo. Even with zero application logic, Go's WASM output starts at hundreds of KB because of the runtime. TinyGo helps enormously but can't eliminate it entirely.

Heavy parsing libraries cost as much as a runtime. html5ever adds ~900KB of compiled code. Comparable to TinyGo's entire runtime. If you're optimizing for size, the parser choice matters as much as the language choice.

Question your abstractions. I didn't need a DOM tree. I didn't need CSS selectors. I didn't need a spec-compliant HTML parser. Removing those abstractions removed 94% of the binary.

opt-level = 'z' + LTO + single codegen unit + strip is table stakes. These Rust release profile settings are mandatory for size-optimized WASM. Without them, you're leaving 2-3x on the table.

panic = "abort" saves ~10-15KB. Removing the unwinding machinery is free if you use Result for error handling. Which you should in WASM anyway.

wasm-opt may not help with modern Rust. Rust 1.97+ emits memory.copy (bulk memory operations) that older wasm-opt versions can't handle. With LTO enabled, LLVM's optimizer already does most of what wasm-opt would.

The numbers that matter

For a tool where every visitor downloads the WASM binary:

  • 52 KB transfers in ~50ms on 3G, ~10ms on 4G
  • 936 KB takes ~900ms on 3G, ~180ms on 4G
  • 7 MB takes 7 seconds on 3G, 1.4 seconds on 4G

That's the difference between instant and "why is nothing happening?"

Try it yourself

The converter is live at html-to-markdown-ai.com. Paste or upload any HTML, get clean Markdown back. Everything runs in your browser. Nothing leaves your machine.

Over to you

Have you gone through a similar WASM size optimization? Or hit a wall where TinyGo or Rust's ecosystem was still too heavy? I'd like to hear about it.

  1. What's your WASM binary size, and have you tried to reduce it?
  2. Did you end up dropping dependencies in favor of custom code?
  3. Any tricks I missed?

Top comments (0)