Advanced Patterns and Architecture

The simple calculator from the last part works fine for demos, but real applications need better structure. I learned this the hard way when my first “real” WebAssembly app became an unmaintainable mess of global functions and tangled state.

The key insight is that WebAssembly applications are essentially distributed systems - your Go code runs in one environment, JavaScript in another, and they communicate across a well-defined boundary. This requires different thinking than typical Go applications.

Designing Clean Boundaries

The biggest architectural decision is how you structure the boundary between Go and JavaScript. I’ve seen apps fail because developers made this boundary too fine-grained (lots of tiny function calls) or too coarse-grained (monolithic modules that are hard to maintain).

Think of your WebAssembly module as a specialized computational engine. Go handles the heavy lifting - complex algorithms, data processing, mathematical computations. JavaScript handles UI concerns and browser integration.

Here’s how I structure a more complex application:

package main

import (
    "encoding/json"
    "syscall/js"
)

type ImageProcessor struct {
    currentImage *ImageData
}

type ImageData struct {
    Width  int `json:"width"`
    Height int `json:"height"`
    // Pixel data handled separately for efficiency
}

func (ip *ImageProcessor) LoadImage(this js.Value, args []js.Value) interface{} {
    // Convert JavaScript ImageData to Go structures
    width := args[0].Int()
    height := args[1].Int()
    
    ip.currentImage = &ImageData{
        Width:  width,
        Height: height,
    }
    
    return map[string]interface{}{
        "success": true,
        "width":   width,
        "height":  height,
    }
}

func main() {
    processor := &ImageProcessor{}
    js.Global().Set("loadImage", js.FuncOf(processor.LoadImage))
    select {}
}

Notice how I’m using structured data types and returning consistent response formats. This makes the code much more maintainable than passing individual parameters everywhere.

State Management Patterns

Managing state across the WebAssembly boundary is tricky. You have state in Go, state in JavaScript, and keeping them synchronized can be a nightmare.

My approach: make Go the authoritative source for computational state, let JavaScript handle UI state. Here’s a simple state manager:

type StateManager struct {
    state map[string]interface{}
}

func (sm *StateManager) UpdateState(key string, value interface{}) {
    sm.state[key] = value
    sm.notifyJavaScript()
}

func (sm *StateManager) notifyJavaScript() {
    stateJSON, _ := json.Marshal(sm.state)
    js.Global().Call("onStateUpdate", string(stateJSON))
}

JavaScript subscribes to state updates and updates the UI accordingly. This keeps the data flow predictable and makes debugging much easier.

Error Handling Across Boundaries

Error handling in WebAssembly apps is more complex because errors can happen in multiple environments. I use a consistent error handling pattern:

func (app *App) ProcessData(this js.Value, args []js.Value) interface{} {
    defer func() {
        if r := recover(); r != nil {
            // Convert panics to structured errors
            app.sendError("panic", fmt.Sprintf("%v", r))
        }
    }()
    
    if len(args) != 1 {
        return map[string]interface{}{
            "success": false,
            "error":   "ProcessData requires exactly one argument",
        }
    }
    
    // Process data...
    
    return map[string]interface{}{
        "success": true,
        "result":  processedData,
    }
}

Every function returns a consistent response format with success/error information. This makes error handling predictable on the JavaScript side.

Modular Architecture

For larger applications, I organize code into modules that handle specific domains. Each module has its own interface to JavaScript:

type Application struct {
    imageProcessor *ImageProcessor
    dataAnalyzer   *DataAnalyzer
    fileHandler    *FileHandler
}

func (app *Application) RegisterFunctions() {
    // Image processing functions
    js.Global().Set("processImage", js.FuncOf(app.imageProcessor.Process))
    
    // Data analysis functions
    js.Global().Set("analyzeData", js.FuncOf(app.dataAnalyzer.Analyze))
    
    // File handling functions
    js.Global().Set("loadFile", js.FuncOf(app.fileHandler.Load))
}

This keeps related functionality grouped together and makes the codebase easier to navigate.

Performance Optimization Patterns

The most important performance insight: minimize boundary crossings. Instead of making many small function calls, batch operations together:

func (bp *BatchProcessor) ProcessBatch(this js.Value, args []js.Value) interface{} {
    // Parse multiple operations from JavaScript
    operations := parseOperations(args[0])
    
    results := make([]interface{}, len(operations))
    for i, op := range operations {
        results[i] = bp.processOperation(op)
    }
    
    return map[string]interface{}{
        "success": true,
        "results": results,
    }
}

This pattern processes multiple operations in a single WebAssembly call, dramatically reducing overhead.

Memory Management Considerations

WebAssembly applications need careful memory management. Go’s garbage collector runs in the WebAssembly environment, but you need to be mindful of memory allocation patterns:

type MemoryEfficientProcessor struct {
    buffer []byte // Reuse buffers when possible
}

func (mep *MemoryEfficientProcessor) Process(data []byte) []byte {
    // Reuse existing buffer if it's large enough
    if cap(mep.buffer) < len(data) {
        mep.buffer = make([]byte, len(data))
    }
    mep.buffer = mep.buffer[:len(data)]
    
    // Process data using the reused buffer
    copy(mep.buffer, data)
    // ... processing logic
    
    return mep.buffer
}

Reusing buffers and minimizing allocations helps keep garbage collection overhead low.

Configuration and Initialization

Complex applications need proper initialization and configuration management:

type Config struct {
    MaxImageSize int    `json:"maxImageSize"`
    Quality      int    `json:"quality"`
    Debug        bool   `json:"debug"`
}

func (app *Application) Initialize(this js.Value, args []js.Value) interface{} {
    var config Config
    if err := json.Unmarshal([]byte(args[0].String()), &config); err != nil {
        return map[string]interface{}{
            "success": false,
            "error":   "Invalid configuration",
        }
    }
    
    app.config = config
    return map[string]interface{}{
        "success": true,
        "message": "Application initialized",
    }
}

This allows JavaScript to configure the WebAssembly module at startup with environment-specific settings.

Testing Strategies

Testing WebAssembly applications requires testing both the Go logic and the JavaScript integration. I separate these concerns:

// Test Go logic independently
func TestImageProcessor(t *testing.T) {
    processor := &ImageProcessor{}
    // Test with Go data structures
}

// Test WebAssembly integration separately
func TestWebAssemblyIntegration(t *testing.T) {
    // This would run in a browser environment
}

Most of your logic should be testable with standard Go tests. Only the browser integration needs special testing.

These patterns have saved me countless hours of debugging and refactoring. The upfront investment in clean architecture pays dividends as applications grow in complexity.

Next, we’ll explore how Go and JavaScript communicate - the interoperability mechanisms that make WebAssembly applications possible.