WebAssembly (wasm) là một đích biên dịch nhị phân chạy được trong trình duyệt ở tốc độ gần native, và ngày càng phổ biến ở phía máy chủ như một sandbox nhẹ. Go hỗ trợ wasm từ 1.11, và Go 1.21 thêm đích WASI. Vậy là cùng một mã Go bạn viết cho server có thể chạy trong tab trình duyệt hoặc trong một runtime wasm độc lập. Bài này đo thật cách biên dịch cả hai đích, chạy wasm bằng runtime, cách gọi qua lại JavaScript, và — điều quan trọng nhất khi cân nhắc dùng thật — kích thước binary.
Hai đích wasm, hai mục đích
Go có hai đích WebAssembly khác nhau, chọn qua biến GOOS:
# Trình duyệt: dùng package syscall/js, cần wasm_exec.js làm keo
GOOS=js GOARCH=wasm go build -o app.wasm
# WASI (server/standalone): chạy bằng wasmtime, wazero, wasmer...
GOOS=wasip1 GOARCH=wasm go build -o app.wasm
GOOS=js sinh wasm dành cho trình duyệt — nó giả định môi trường có JavaScript và dùng package syscall/js để tương tác. GOOS=wasip1 sinh wasm theo chuẩn WASI (WebAssembly System Interface) — chạy trong runtime wasm độc lập ngoài trình duyệt, có quyền truy cập hệ thống (file, stdin/stdout) qua giao diện WASI chuẩn. Cùng một mã Go, chỉ đổi GOOS/GOARCH.

Hình 1: Hai đích — GOOS=js cho trình duyệt (dùng syscall/js, cần wasm_exec.js), GOOS=wasip1 cho WASI standalone. syscall/js cho phép đăng ký hàm Go để JavaScript gọi và thao tác DOM.
Chạy thật: mã Go nguyên vẹn trong wasm
wasm không chỉ biên dịch được mà chạy thật. Dùng wazero — một runtime wasm viết bằng Go thuần — để chạy binary WASI:
$ wazero run app_wasip1.wasm
Xin chào từ WebAssembly!
fib(40) = 102334155
Đo thật trong go-lab (Go 1.23): fmt.Println in ra qua stdout của WASI, fib(40) tính đúng 102334155. Toàn bộ runtime Go — goroutine, GC, map, slice — chạy nguyên trong sandbox wasm. Đây không phải một tập con hạn chế của Go; đó là Go đầy đủ, chỉ biên dịch sang một đích khác.
syscall/js: cầu nối với JavaScript
Với đích trình duyệt, package syscall/js là cách Go nói chuyện với thế giới JavaScript. Bạn đăng ký hàm Go cho JS gọi, và thao tác được DOM:
//go:build js && wasm
import "syscall/js"
js.Global().Set("congGo", js.FuncOf(func(this js.Value, args []js.Value) any {
return args[0].Int() + args[1].Int() // JS: congGo(3,4) -> 7
}))
Sau khi wasm nạp, JavaScript gọi congGo(3, 4) và nhận 7 từ Go. Ngược lại, Go đọc/ghi DOM qua js.Global().Get("document"). Trên trình duyệt, wasm cần một file keo wasm_exec.js (16.687 byte, do Go phát hành) để khởi tạo runtime Go trước khi go.run() chạy binary. Đây là cơ chế cho các ứng dụng Go chạy hoàn toàn phía client — từ công cụ xử lý ảnh tới game.
Đo thật cái giá: kích thước binary
Đây là điều phải cân nhắc nghiêm túc trước khi dùng Go wasm cho web. Biên dịch cùng một chương trình nhỏ ra ba đích:

Hình 2: Cùng mã — js/wasm 2.383.451 byte, wasip1/wasm 2.360.548 byte, native linux/arm64 2.155.611 byte. wasm ~2,3 MB vì gói cả runtime Go. Strip (-ldflags="-s -w") chỉ giảm còn 2.317.067 — bớt ~43 KB.
- js/wasm: 2.383.451 byte (~2,38 MB).
- wasip1/wasm: 2.360.548 byte.
- native linux/arm64: 2.155.611 byte (để so).
wasm ~2,3-2,4 MB vì nó gói cả runtime Go — scheduler, garbage collector, toàn bộ hệ thống chạy goroutine — vào binary. Không như C hay Rust (runtime tối thiểu), Go mang cả cỗ máy đi theo. Với web, đây là điểm đau: người dùng phải tải 2,3 MB trước khi trang chạy được. Và strip giúp rất ít: -ldflags="-s -w" chỉ giảm xuống 2.317.067 — bớt vỏn vẹn ~43 KB, vì phần lớn kích thước là runtime không cắt được.
Đánh đổi cần cân nhắc
Go wasm hợp cho ứng dụng, không hợp cho widget nhỏ. 2,3 MB là quá lớn cho một nút bấm hay một hiệu ứng nhỏ trên trang — dùng JavaScript thuần đúng hơn. Nhưng cho một ứng dụng client phức tạp (trình chỉnh sửa, công cụ tính toán, game) mà bạn muốn tái dùng logic Go từ server, 2,3 MB tải một lần rồi cache lại là chấp nhận được. Cân theo tỷ lệ giữa kích thước tải và giá trị ứng dụng.
Muốn wasm nhỏ thật thì dùng TinyGo, nhưng mất mát. TinyGo — một compiler Go thay thế — sinh wasm nhỏ tới hàng chục KB vì dùng runtime tối giản. Đổi lại, nó không hỗ trợ đầy đủ thư viện chuẩn (reflect hạn chế, một số package không dùng được) và một số hành vi khác Go chính thống. Với widget nhỏ hoặc thiết bị nhúng, TinyGo là lựa chọn; với ứng dụng cần thư viện chuẩn đầy đủ, phải chấp nhận kích thước của Go chính thống.
WASI mở ra dùng wasm ngoài trình duyệt. GOOS=wasip1 không chỉ là "wasm cho server" — nó cho phép chạy code Go trong sandbox wasm an toàn (plugin, hàm serverless, edge computing) với runtime như wasmtime hay wazero. Đây là hướng đang lớn nhanh: một binary wasm chạy được trên mọi runtime WASI, không phụ thuộc OS/kiến trúc — tính di động còn cao hơn cả binary native của Go.
Ba ý mang về
- Go biên dịch sang WebAssembly qua hai đích:
GOOS=js GOARCH=wasmcho trình duyệt (dùngsyscall/jsgọi qua lại JavaScript, cầnwasm_exec.jslàm keo) vàGOOS=wasip1 GOARCH=wasmcho WASI standalone — đo thật, mã Go chạy nguyên trong wasm (fib(40)=102334155qua wazero). - Binary wasm ~2,3 MB vì gói cả runtime Go (scheduler, GC): đo thật js/wasm 2.383.451 byte, wasip1 2.360.548 byte — lớn cho web vì người dùng phải tải hết trước khi chạy, và strip chỉ bớt ~43 KB.
- Muốn wasm nhỏ (chục KB) phải dùng TinyGo nhưng đổi lấy thư viện chuẩn hạn chế — chọn Go chính thống cho ứng dụng cần đầy đủ stdlib, TinyGo cho widget nhỏ; WASI mở ra dùng wasm như sandbox di động ngoài trình duyệt.
Phần sau ta xuống tầng thấp nhất của Go, nơi kiểu an toàn bị gạt sang bên: Phần sau mổ xẻ unsafe.Pointer — bốn mẫu chuyển đổi hợp lệ mà runtime bảo đảm, và vì sao đi chệch chúng là hành vi không xác định.