~/krishna_dhakal
#Go#WebAssembly#TypeScript#Performance

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


  1. **WASM binary size** — Go's garbage collector and runtime ship with every binary. TinyGo can reduce size but loses some stdlib compatibility.
  2. **Debugging** — stack traces from WASM are cryptic; source maps help but are not perfect.
  3. **Memory sharing** — passing large files between JS and WASM requires careful use of the WASM linear memory buffer.
  4. **Browser compatibility** — WASM is well-supported, but streaming instantiation (`instantiateStreaming`) needs HTTPS or localhost.

> Key Takeaways


  1. **WASM is a great fit when you have an existing Go library** — rewriting core crypto and storage logic in JS is risky.
  2. **Always wrap WASM with a typed TypeScript layer** — callers should never touch raw WASM globals.
  3. **Use Web Workers for heavy WASM work** — blocking the main thread destroys user experience.
  4. **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.