Debugging and Testing
Debugging WebAssembly apps frustrated me more than any other aspect of development. The familiar Go debugging tools don’t work the same way, browser dev tools show JavaScript interfaces rather than Go code, and errors often manifest in unexpected ways.
But I’ve developed systematic approaches that make WebAssembly debugging manageable. The key is understanding that you’re debugging a distributed system with Go code in one environment and JavaScript in another.
Setting Up Debugging
The foundation of WebAssembly debugging is having good logging and error handling built into your application from the start:
type Debugger struct {
logLevel string
console js.Value
}
func NewDebugger() *Debugger {
return &Debugger{
logLevel: "info",
console: js.Global().Get("console"),
}
}
func (d *Debugger) Log(level, message string, data interface{}) {
if !d.shouldLog(level) {
return
}
logEntry := map[string]interface{}{
"level": level,
"message": message,
"data": data,
"source": "go-wasm",
"time": time.Now().Format(time.RFC3339),
}
jsonData, _ := json.Marshal(logEntry)
switch level {
case "error":
d.console.Call("error", string(jsonData))
case "warn":
d.console.Call("warn", string(jsonData))
default:
d.console.Call("log", string(jsonData))
}
}
func (d *Debugger) shouldLog(level string) bool {
levels := map[string]int{
"debug": 0, "info": 1, "warn": 2, "error": 3,
}
return levels[level] >= levels[d.logLevel]
}
This provides structured logging that shows up clearly in browser dev tools.
Error Handling Patterns
WebAssembly error handling requires catching errors in both Go and JavaScript environments:
func SafeFunction(this js.Value, args []js.Value) interface{} {
defer func() {
if r := recover(); r != nil {
debugger.Log("error", "Function panicked", map[string]interface{}{
"panic": fmt.Sprintf("%v", r),
"stack": string(debug.Stack()),
})
}
}()
if len(args) == 0 {
return map[string]interface{}{
"success": false,
"error": "No arguments provided",
}
}
// Your function logic here
return map[string]interface{}{
"success": true,
"result": "Operation completed",
}
}
Always return structured responses that JavaScript can handle predictably.
Testing Strategies
Testing WebAssembly applications requires testing both Go logic and JavaScript integration separately:
// Test Go logic with standard Go tests
func TestBusinessLogic(t *testing.T) {
result := calculateSomething(10, 20)
if result != 30 {
t.Errorf("Expected 30, got %d", result)
}
}
// Test WebAssembly integration with browser automation
func TestWebAssemblyIntegration(t *testing.T) {
// This would run in a browser environment
// using tools like Playwright or Selenium
}
Most of your logic should be testable with standard Go tests. Only the browser integration needs special testing.
Browser Debugging Techniques
Browser dev tools are your primary debugging interface. Here’s how I use them effectively:
- Console Tab: All your Go
fmt.Println()
anddebugger.Log()
calls show up here - Network Tab: Monitor WebAssembly module loading and any HTTP requests
- Performance Tab: Profile WebAssembly execution and identify bottlenecks
- Memory Tab: Track memory usage and garbage collection
For complex debugging, I create debug endpoints:
func GetDebugInfo(this js.Value, args []js.Value) interface{} {
var memStats runtime.MemStats
runtime.ReadMemStats(&memStats)
return map[string]interface{}{
"goroutines": runtime.NumGoroutine(),
"memory_alloc": memStats.Alloc,
"gc_cycles": memStats.NumGC,
"uptime": time.Since(startTime).String(),
}
}
This gives you runtime information that’s invaluable for debugging performance issues.
Common Debugging Scenarios
I’ve encountered these debugging scenarios repeatedly:
Memory Issues: Use the browser’s Memory tab to identify leaks. Look for growing memory usage over time.
Performance Problems: Use the Performance tab to see where time is spent. Often the issue is too many boundary crossings.
Integration Bugs: These usually involve data conversion between Go and JavaScript. Add logging at conversion points.
Startup Issues: WebAssembly modules can fail to load for various reasons. Check the Network tab for loading errors.
Testing Framework
I’ve built a simple testing framework for WebAssembly applications:
type TestSuite struct {
tests []Test
results []TestResult
}
type Test struct {
Name string
Func func() TestResult
}
type TestResult struct {
Name string
Passed bool
Error string
Duration time.Duration
}
func (ts *TestSuite) AddTest(name string, testFunc func() TestResult) {
ts.tests = append(ts.tests, Test{Name: name, Func: testFunc})
}
func (ts *TestSuite) RunTests(this js.Value, args []js.Value) interface{} {
ts.results = make([]TestResult, 0)
for _, test := range ts.tests {
start := time.Now()
result := test.Func()
result.Name = test.Name
result.Duration = time.Since(start)
ts.results = append(ts.results, result)
}
return ts.formatResults()
}
func (ts *TestSuite) formatResults() map[string]interface{} {
passed := 0
for _, result := range ts.results {
if result.Passed {
passed++
}
}
return map[string]interface{}{
"total": len(ts.results),
"passed": passed,
"failed": len(ts.results) - passed,
"results": ts.results,
}
}
This provides a way to run tests directly in the browser and see results.
Debugging Tools
For complex applications, I create debugging tools that help understand what’s happening:
func DumpState(this js.Value, args []js.Value) interface{} {
return map[string]interface{}{
"application_state": getCurrentState(),
"active_goroutines": runtime.NumGoroutine(),
"memory_stats": getMemoryStats(),
"performance_data": getPerformanceData(),
}
}
func EnableDebugMode(this js.Value, args []js.Value) interface{} {
debugMode = true
debugger.logLevel = "debug"
return "Debug mode enabled"
}
These functions give you insight into your application’s internal state.
Integration Testing
For integration testing, I use a combination of Go tests and browser automation:
// Browser-side test runner
async function runIntegrationTests() {
const tests = [
{ name: "Basic Function Call", test: testBasicFunction },
{ name: "Data Processing", test: testDataProcessing },
{ name: "Error Handling", test: testErrorHandling }
];
const results = [];
for (const test of tests) {
try {
await test.test();
results.push({ name: test.name, passed: true });
} catch (error) {
results.push({ name: test.name, passed: false, error: error.message });
}
}
return results;
}
This approach tests the complete integration between JavaScript and WebAssembly.
Performance Debugging
Performance issues in WebAssembly often stem from boundary crossing overhead or inefficient algorithms:
func ProfileFunction(name string, fn func()) {
start := time.Now()
fn()
duration := time.Since(start)
debugger.Log("perf", "Function timing", map[string]interface{}{
"function": name,
"duration": duration.Milliseconds(),
})
}
Use this to identify slow functions and optimize them.
Debugging Checklist
When debugging WebAssembly issues, I follow this checklist:
- Check browser console for errors
- Verify WebAssembly module loaded successfully
- Test Go functions independently
- Check data conversion at JavaScript boundary
- Monitor memory usage and garbage collection
- Profile performance with browser tools
- Test with different browsers and environments
Debugging WebAssembly feels different from regular Go debugging, but these tools and techniques make it manageable. The key is building debugging capabilities into your applications from the start, not adding them after problems appear.
Advanced WebAssembly features come next - techniques for building more sophisticated applications that push the boundaries of what’s possible in browsers.