Migrating a Go Storage System to TypeScript and WebAssembly
> September 20, 2021
When browser clients needed direct access to 0chain's Go storage SDK, the team faced a choice: rewrite in JavaScript, or compile Go to WebAssembly. We chose WASM — and it was mostly the right call. Here's what we learned.
> The Problem with a Pure Go Backend for Browser Clients
The original architecture required all storage operations to go through a backend server: the browser talked to our server, our server talked to the blockchain. This introduced latency, a single point of failure, and scaling costs.
The goal was to enable the browser to communicate directly with blockchain nodes — without an intermediary server. Since the core SDK was in Go, the path of least resistance was compiling it to WASM.
> Compiling Go to WebAssembly
Go has first-class WASM support:
GOOS=js GOARCH=wasm go build -o main.wasm ./cmd/wasm
cp "$(go env GOROOT)/misc/wasm/wasm_exec.js" ./public/Expose Go functions to JavaScript using `syscall/js`:
//go:build js && wasm
package main
import (
"encoding/json"
"syscall/js"
)
func uploadFile(this js.Value, args []js.Value) interface{} {
allocationID := args[0].String()
remotePath := args[1].String()
fileBytes := args[2] // ArrayBuffer
// perform upload using the Go SDK
result, err := sdk.UploadFileBrowser(allocationID, remotePath, toGoBytes(fileBytes))
if err != nil {
return map[string]interface{}{"error": err.Error()}
}
data, _ := json.Marshal(result)
return map[string]interface{}{"result": string(data)}
}
func main() {
js.Global().Set("zboxUploadFile", js.FuncOf(uploadFile))
// keep the Go runtime alive
select {}
}> TypeScript Wrapper Layer
Raw WASM calls are unergonomic from TypeScript. We wrote a typed wrapper that hid all the WASM plumbing:
declare const zboxUploadFile: (allocationID: string, remotePath: string, data: ArrayBuffer) => {
result?: string;
error?: string;
};
async function loadWasm(): Promise<void> {
const go = new Go();
const result = await WebAssembly.instantiateStreaming(fetch('/main.wasm'), go.importObject);
go.run(result.instance);
}
export async function uploadFile(allocationID: string, remotePath: string, file: File): Promise<UploadResult> {
const buffer = await file.arrayBuffer();
const response = zboxUploadFile(allocationID, remotePath, buffer);
if (response.error) throw new Error(response.error);
return JSON.parse(response.result!);
}> Performance Improvements
The WASM approach eliminated the server intermediary for read-heavy operations:
- **Download latency** dropped by ~40% — no proxy hop
- **Concurrent transfers** improved — browser handles parallelism natively via Web Workers
- **Bundle size** was the trade-off — the WASM binary was ~8MB; we used lazy loading to mitigate startup impact
> Loading WASM in a Web Worker
For CPU-intensive operations, moving WASM to a Web Worker prevents main thread blocking:
// worker.ts
importScripts('/wasm_exec.js');
const go = new Go();
WebAssembly.instantiateStreaming(fetch('/main.wasm'), go.importObject).then(result => {
go.run(result.instance);
self.postMessage({ ready: true });
});
self.onmessage = (event) => {
const { type, payload } = event.data;
if (type === 'upload') {
const result = zboxUploadFile(payload.allocationID, payload.remotePath, payload.data);
self.postMessage({ type: 'upload_result', result });
}
};> Pitfalls We Hit
- **WASM binary size** — Go's garbage collector and runtime ship with every binary. TinyGo can reduce size but loses some stdlib compatibility.
- **Debugging** — stack traces from WASM are cryptic; source maps help but are not perfect.
- **Memory sharing** — passing large files between JS and WASM requires careful use of the WASM linear memory buffer.
- **Browser compatibility** — WASM is well-supported, but streaming instantiation (`instantiateStreaming`) needs HTTPS or localhost.
> Key Takeaways
- **WASM is a great fit when you have an existing Go library** — rewriting core crypto and storage logic in JS is risky.
- **Always wrap WASM with a typed TypeScript layer** — callers should never touch raw WASM globals.
- **Use Web Workers for heavy WASM work** — blocking the main thread destroys user experience.
- **Measure first** — WASM is not always faster than well-optimized JS for simpler tasks.
The migration succeeded, but it required discipline to keep the TypeScript surface clean and the WASM internals hidden from application code.