Choosing Between Emscripten, wasm-pack, and TinyGo

This guide answers a single question: given the language you already have code in and the size and interop constraints of your project, which WebAssembly toolchain should you reach for — Emscripten, wasm-pack, or TinyGo? It compares the three on source language, output size, glue weight, JavaScript interop ergonomics, and the workloads each one fits.

Prerequisites

  • [ ] A target language already chosen or in hand (C/C++, Rust, or Go)
  • [ ] One of: Emscripten (emcc), wasm-pack (+ Rust), or TinyGo installed
  • [ ] wasm-opt (Binaryen) and wasm-objdump (wabt) for measuring and inspecting output
  • [ ] A clear deliverable target — browser ESM, Node, or a standalone runtime

The three toolchains at a glance

These are not interchangeable; each is bound to a source language and a runtime philosophy. Emscripten compiles C and C++ and emulates a POSIX-like environment in JavaScript. wasm-pack wraps the Rust compiler and wasm-bindgen to emit a small, GC-free module with a typed JS boundary. TinyGo is an alternative Go compiler (the standard go toolchain also targets Wasm, but produces multi-megabyte binaries) that fits Go into a far smaller module by shipping a tiny runtime and a conservative garbage collector.

Dimension Emscripten wasm-pack TinyGo
Source language C / C++ Rust Go (subset)
Typical “hello world” .wasm 8–20 KB + glue 12–30 KB 30–60 KB
JS glue weight 30–100+ KB (full runtime) ~10–20 KB, tree-shakeable tiny wasm_exec.js shim (~15 KB)
Interop ergonomics Embind / ccall / EM_JS wasm-bindgen (typed .d.ts) syscall/js (untyped, verbose)
Garbage collector none (manual malloc/free) none (ownership) yes, conservative
Best fit porting existing C/C++ libraries new perf code, typed JS API reusing Go logic, small surface

The numbers are order-of-magnitude, not guarantees — they move with optimization flags and how much standard library you pull in. The point is the relative shape: Emscripten’s binary is small but its glue is heavy; wasm-pack balances both with the best interop; TinyGo’s binary is the largest of the three at the low end but ships almost no glue.

The deeper distinction is the runtime model. Emscripten and wasm-pack both emit modules with no managed runtime — there is no garbage collector, no scheduler, no allocator beyond a small malloc/free, so the binary contains essentially just your compiled logic. Memory is managed manually © or by the compiler via ownership (Rust). TinyGo is different in kind: Go is a managed language, so even a minimal TinyGo module must bundle a garbage collector and a goroutine scheduler. That baked-in runtime is why TinyGo’s smallest binary is larger than the other two, and why its compute is slower under allocation pressure — but it is also what makes Go code “just work” without you thinking about memory at all. Choosing a toolchain is therefore as much a choice about who manages memory as it is about which language you write.

A decision walkthrough

Work down these questions in order — the first one that matches usually decides it:

  1. Do you already have the logic in C or C++? Use Emscripten. Rewriting a mature C library in another language to save glue bytes is almost never worth it; emcc will compile it today, and Embind gives you clean class bindings. The companion C/C++ to Wasm with Emscripten guide covers the port end to end.
  2. Is this new performance-critical code with a rich JavaScript API surface? Use wasm-pack. Rust’s ownership model means no GC, the smallest practical binaries, and wasm-bindgen gives you a typed boundary with generated .d.ts — the best interop ergonomics of the three.
  3. Do you have existing Go code, or a team fluent in Go, and a small interop surface? Use TinyGo. It is the pragmatic way to reuse Go business logic in the browser without a multi- megabyte standard go build Wasm binary, as long as you do not lean on the parts of the standard library TinyGo omits.
  4. Is binary size the single overriding constraint and the logic is trivial? Consider hand-written WAT or raw Rust with no wasm-bindgen — but that is a different project shape, not a toolchain choice among these three.

Expected output: size comparison

A function that takes a string and returns a transformed string, built with each toolchain at its size-optimized settings, lands roughly here after wasm-opt -Oz:

# Emscripten:  emcc add.c -Oz -s STANDALONE_WASM -o add.wasm
add.wasm        ~9 KB        # small binary...
add.js          ~42 KB       # ...but the runtime glue dominates total payload

# wasm-pack:   wasm-pack build --release --target web
wasm_core_bg.wasm  ~14 KB    # small binary
wasm_core.js       ~12 KB    # lean, tree-shakeable glue — smallest total

# TinyGo:      tinygo build -o main.wasm -target wasm -no-debug ./
main.wasm       ~38 KB       # GC + runtime baked in
wasm_exec.js    ~15 KB       # fixed shim shared by all TinyGo builds

Inspect any of them the same way to confirm the export you expect survived:

wasm-objdump -x add.wasm | grep -A5 'Export\['

The takeaway: for total bytes over the wire, wasm-pack usually wins because its glue is the lightest. Emscripten’s binary is smallest in isolation but its JavaScript runtime erases that lead. TinyGo’s binary carries a garbage collector you cannot remove.

The interop story differs just as much as the size story. With wasm-pack, calling from JavaScript looks like calling a typed module — import init, { greet } from "./pkg/wasm_core.js", then greet("Ada"), with autocomplete from the generated .d.ts. With Emscripten you either reach for Module.ccall("add", "number", ["number"], [2]) for plain C functions or define class bindings with Embind so C++ objects appear as JavaScript objects. With TinyGo you write Go that imports syscall/js and reaches into the DOM by hand:

package main

import "syscall/js"

func add(this js.Value, args []js.Value) any {
    return args[0].Int() + args[1].Int()
}

func main() {
    js.Global().Set("add", js.FuncOf(add))
    select {} // keep the Go runtime alive
}

That syscall/js style is functional but verbose and untyped — every value crossing the boundary is a js.Value you unwrap manually, and there is no generated declaration file. It is the price of TinyGo’s tiny glue: the work wasm-bindgen does at build time, you do by hand at runtime.

Gotchas

  • TinyGo standard-library gaps. TinyGo implements a subset of Go. Packages touching reflection-heavy serialization (encoding/json works but is limited), net/http servers, or goroutine-heavy concurrency may fail to compile with package X is not yet supported or behave differently. Validate your imports against TinyGo’s support matrix before committing — discovering a gap mid-port is the most common TinyGo regret.
  • Emscripten glue size. A default emcc build links a large JavaScript runtime (filesystem shim, environment emulation) that can dwarf the .wasm. Strip it with -s STANDALONE_WASM, -s FILESYSTEM=0, -s ENVIRONMENT=web, and --closure 1, or you ship tens of kilobytes of glue you never call.
  • wasm-pack needs Rust. wasm-pack is not a general compiler — it only builds Rust crates. If your code is in C, Go, or anything else, wasm-pack is simply not an option; the choice is Emscripten or TinyGo.
  • TinyGo GC pauses. TinyGo’s conservative collector can introduce small, unpredictable pauses under allocation pressure. For hard-real-time audio or per-frame work, a GC-free Rust or C module gives more predictable latency.

Performance note

For compute-bound numeric kernels, Emscripten (C/C++) and wasm-pack (Rust) land within a few percent of each other and of native — both emit tight, LLVM-optimized code with no runtime overhead per call. TinyGo runs measurably slower on allocation-heavy code because of its garbage collector and a less aggressive optimizer, often 1.5–3× the Rust time on the same hot loop. If the workload is a tight number-crunching inner loop, prefer Rust or C; if it is mostly glue around existing Go logic, TinyGo’s slower compute rarely matters. Apply the same size flags from Wasm optimization flags & size reduction to whichever toolchain you pick.

Mixing toolchains in one project

These choices are per-module, not per-project, and a single application can load modules from more than one toolchain. A common pattern is an Emscripten-compiled C codec (because the codec already exists in C and rewriting it is unthinkable) sitting next to a small Rust module built with wasm-pack that orchestrates it and exposes a clean typed API to the application. Each .wasm is an independent instance with its own linear memory; they do not share heaps, so data passed between them crosses through JavaScript or through a shared SharedArrayBuffer you set up deliberately. The cost of mixing is the union of their glue and runtime weights, so do it when reuse genuinely saves a rewrite, not to chase a marginal performance win. When you do mix, apply each toolchain’s own size discipline independently — strip Emscripten’s filesystem runtime, run -Oz on the Rust module — rather than assuming one set of flags covers both.

Frequently Asked Questions

Can I use plain go build instead of TinyGo for browser Wasm? You can — Go has supported GOOS=js GOARCH=wasm for years — but the output is typically 2–10 MB because the full Go runtime and garbage collector ship in the binary. TinyGo exists precisely to make Go’s Wasm output small enough for the browser; use standard Go Wasm only when binary size is irrelevant.

Which gives the best TypeScript experience? wasm-pack. wasm-bindgen generates a .d.ts describing the exact boundary, so your editor knows the types of every exported function. Emscripten’s Embind and TinyGo’s syscall/js both leave you writing untyped JavaScript glue or hand-authored declarations.

Is there a single toolchain that compiles all three languages? No — each is tied to its source language. Emscripten is C/C++, wasm-pack is Rust, TinyGo is Go. The decision starts from the language your code is already in, which is why language is the first question in the walkthrough above.

← Back to Rust to Wasm Compilation Guide