Debugging WebAssembly in Browsers: DevTools, DWARF, and Source Maps

Debugging WebAssembly in Browsers: DevTools, DWARF, and Source Maps

Debugging WebAssembly in browsers has historically been painful — you were staring at raw WAT (WebAssembly Text format) with cryptic variable names like $var0 and $var1. That's changed dramatically. Chrome 90+ and Firefox support DWARF debug information, letting you set breakpoints in your original Rust, C, or AssemblyScript source. This guide covers the full WASM debugging toolkit and how to write tests that help you debug faster.

The WASM Debugging Ecosystem

Before diving in, understand what tools exist:

  • Chrome DevTools — best WASM debugging support via the C/C++ DevTools Extension and DWARF support
  • Firefox DevTools — good WASM support with a WASM disassembly view
  • Chrome DevTools Protocol (CDP) — programmatic debugging access used by Playwright/Puppeteer
  • WASM source maps — like JS source maps, map binary positions to source locations
  • DWARF debug info — embedded debug information (Rust with --debug, emcc -g4)
  • wasm-pack --dev — Rust WASM with debug symbols
  • Binaryen wasm-objdump — inspect WASM binary contents

Enabling WASM Debug Information

Rust / wasm-pack

# Development build with full debug info
wasm-pack build --dev

# Or manually
cargo build --target wasm32-unknown-unknown

This generates WASM with DWARF debug sections. Chrome DevTools with the DWARF extension can use this to step through your Rust source.

C/C++ with Emscripten

# -g4 includes DWARF debug info
emcc -g4 src/module.c -o dist/module.js

# -gsource-map generates source maps
emcc -gsource-map src/module.c -o dist/module.js

AssemblyScript

# --sourceMap generates source maps
asc assembly/index.ts --outFile dist/module.wasm --sourceMap

Chrome DevTools WASM Debugging

Setting Up the C/C++ DevTools Extension

  1. Install the C/C++ DevTools Support extension from the Chrome Web Store
  2. Open DevTools → Settings → Experiments → Enable "WebAssembly Debugging: Enable DWARF support"
  3. Reload DevTools

Now you can:

  • Set breakpoints in original Rust/C source files (they appear in the Sources panel)
  • Inspect local variables by name (not $var0)
  • Step through code at the source level

Debugging Without DWARF

If you don't have debug symbols, you're working with WAT. In DevTools:

  1. Open Sources panel → find the .wasm file (it appears as disassembled WAT)
  2. You'll see something like:
(func $process_image (param i32 i32 i32)
  local.get 0
  local.get 1
  i32.mul
  ...)
  1. You can still set breakpoints in this view

Inspecting WASM Memory

// In DevTools Console during a breakpoint:
const memory = new Uint8Array(instance.exports.memory.buffer);

// Read 100 bytes from offset 0
memory.slice(0, 100);

// Check if a pointer looks valid (non-zero, within bounds)
const ptr = // your pointer value from a variable
memory[ptr]; // First byte at that address

Programmatic WASM Debugging with Playwright

For automated debugging and testing, Playwright gives you CDP access:

import { test, expect, chromium } from '@playwright/test';

test('capture WASM execution trace', async () => {
    const browser = await chromium.launch({ devtools: true });
    const page = await browser.newPage();
    
    const cdpSession = await page.context().newCDPSession(page);
    
    // Enable Debugger domain
    await cdpSession.send('Debugger.enable');
    
    // Listen for script parsing (WASM scripts appear here)
    const wasmScripts = [];
    cdpSession.on('Debugger.scriptParsed', (script) => {
        if (script.url.endsWith('.wasm')) {
            wasmScripts.push(script);
        }
    });
    
    await page.goto('http://localhost:3000');
    
    // WASM should be loaded
    expect(wasmScripts.length).toBeGreaterThan(0);
    
    const wasmScript = wasmScripts[0];
    console.log(`WASM script: ${wasmScript.url}`);
    console.log(`Script ID: ${wasmScript.scriptId}`);
    
    await browser.close();
});

test('capture JavaScript console errors from WASM', async ({ page }) => {
    const consoleErrors = [];
    
    page.on('console', msg => {
        if (msg.type() === 'error') {
            consoleErrors.push(msg.text());
        }
    });
    
    page.on('pageerror', error => {
        consoleErrors.push(error.message);
    });
    
    await page.goto('http://localhost:3000');
    await page.click('[data-testid="run-wasm-operation"]');
    await page.waitForTimeout(1000);
    
    // No WASM traps or errors should have occurred
    const wasmErrors = consoleErrors.filter(e => 
        e.includes('wasm') || e.includes('WebAssembly') || e.includes('trap')
    );
    expect(wasmErrors).toHaveLength(0);
});

Testing for WASM Traps

WASM "traps" are runtime errors — divide by zero, out-of-bounds memory access, unreachable instruction, etc. They throw JavaScript errors. Test that your code handles them:

describe('WASM Trap Handling', () => {
    it('catches out-of-bounds memory access', () => {
        expect(() => {
            wasm.read_at_offset(-1); // Invalid pointer
        }).toThrow(/WebAssembly.RuntimeError|out of bounds/i);
    });
    
    it('catches integer overflow trap', () => {
        expect(() => {
            wasm.checked_divide(10, 0); // Integer division by zero
        }).toThrow(/WebAssembly.RuntimeError|integer divide by zero/i);
    });
    
    it('handles unreachable instruction', () => {
        expect(() => {
            wasm.trigger_unreachable();
        }).toThrow(/WebAssembly.RuntimeError|unreachable/i);
    });
    
    it('catches stack overflow', () => {
        expect(() => {
            wasm.infinite_recursion();
        }).toThrow(/WebAssembly.RuntimeError|call stack/i);
    });
});

describe('Trap Recovery', () => {
    it('application continues after caught WASM trap', () => {
        // Catch a trap
        expect(() => wasm.bad_operation()).toThrow();
        
        // Application should still work after the trap
        expect(wasm.good_operation(5)).toBe(25);
    });
    
    it('does not corrupt WASM state after trap', () => {
        const initialValue = wasm.get_counter();
        
        try {
            wasm.bad_operation();
        } catch (e) {
            // Expected
        }
        
        // Counter should not have changed
        expect(wasm.get_counter()).toBe(initialValue);
    });
});

Debugging WASM Memory Leaks

WASM linear memory doesn't have automatic garbage collection. Test for leaks:

describe('Memory Leak Detection', () => {
    it('frees allocated memory after operation', () => {
        const wasm = getWasmInstance();
        
        // Record initial memory usage
        const getMemoryUsage = () => {
            // Find the high-water mark in WASM memory
            return wasm.exports.get_allocated_bytes?.() ?? 
                   new Uint8Array(wasm.exports.memory.buffer).byteLength;
        };
        
        const initial = getMemoryUsage();
        
        // Perform an operation that allocates temporary buffers
        for (let i = 0; i < 100; i++) {
            wasm.exports.process_and_free(1000);
        }
        
        // Force any deferred frees
        wasm.exports.flush_allocator?.();
        
        const afterOperations = getMemoryUsage();
        
        // Memory should return to near-initial levels
        const leaked = afterOperations - initial;
        expect(leaked).toBeLessThan(1024); // Less than 1KB leaked
    });
    
    it('processes_image cleans up after itself', () => {
        const before = wasm.exports.heap_size();
        
        for (let i = 0; i < 50; i++) {
            const result = wasm.process_image(testImageData);
            // Simulate proper cleanup
            result.free?.();
        }
        
        const after = wasm.exports.heap_size();
        expect(after).toBeCloseTo(before, -2); // Within ~100 bytes
    });
});

Source Map Validation Testing

Before debugging, verify your source maps are correct:

// tests/debug/source-maps.test.js
import fs from 'fs';
import path from 'path';
import sourceMapLib from 'source-map';

describe('WASM Source Maps', () => {
    it('source map file exists and is valid', () => {
        const sourceMapPath = path.join('dist', 'module.wasm.map');
        expect(fs.existsSync(sourceMapPath)).toBe(true);
        
        const sourceMap = JSON.parse(fs.readFileSync(sourceMapPath, 'utf8'));
        expect(sourceMap.version).toBe(3);
        expect(sourceMap.sources).toBeDefined();
        expect(sourceMap.mappings).toBeDefined();
    });
    
    it('source map references existing source files', async () => {
        const sourceMapPath = path.join('dist', 'module.wasm.map');
        const rawSourceMap = JSON.parse(fs.readFileSync(sourceMapPath, 'utf8'));
        
        const consumer = await new sourceMapLib.SourceMapConsumer(rawSourceMap);
        
        const sources = consumer.sources;
        for (const source of sources) {
            // Relative paths should resolve to existing files
            const absolutePath = path.resolve('dist', source);
            expect(fs.existsSync(absolutePath)).toBe(true);
        }
        
        consumer.destroy();
    });
    
    it('binary offset maps to meaningful source location', async () => {
        const sourceMapPath = path.join('dist', 'module.wasm.map');
        const rawSourceMap = JSON.parse(fs.readFileSync(sourceMapPath, 'utf8'));
        
        const consumer = await new sourceMapLib.SourceMapConsumer(rawSourceMap);
        
        // The first mapped offset should point to a real source location
        let mappingsExist = false;
        consumer.eachMapping(mapping => {
            if (!mappingsExist) {
                expect(mapping.source).toBeTruthy();
                expect(mapping.originalLine).toBeGreaterThan(0);
                mappingsExist = true;
            }
        });
        
        expect(mappingsExist).toBe(true);
        consumer.destroy();
    });
});

Automated Debugging Artifacts in CI

When WASM tests fail, good debugging artifacts save hours:

// playwright.config.js
export default {
    use: {
        // Capture traces on failure for WASM debugging
        trace: 'retain-on-failure',
        screenshot: 'only-on-failure',
        video: 'retain-on-failure',
    },
    reporter: [
        ['html'],
        ['json', { outputFile: 'test-results/results.json' }],
    ],
};
test('WASM operation with debug artifacts', async ({ page }) => {
    const consoleMessages = [];
    const wasmErrors = [];
    
    page.on('console', msg => {
        consoleMessages.push({ type: msg.type(), text: msg.text() });
    });
    
    page.on('pageerror', error => {
        wasmErrors.push({
            message: error.message,
            stack: error.stack
        });
    });
    
    await page.goto('http://localhost:3000');
    
    // Run the WASM operation
    const result = await page.evaluate(async () => {
        try {
            const mod = await import('/wasm-module.js');
            await mod.default();
            return { success: true, value: mod.process(100) };
        } catch (e) {
            return { success: false, error: e.message, stack: e.stack };
        }
    });
    
    if (!result.success) {
        console.log('WASM failed. Console messages:', consoleMessages);
        console.log('WASM errors:', wasmErrors);
        console.log('WASM error:', result.error);
    }
    
    expect(result.success).toBe(true);
    expect(result.value).toBeDefined();
});

WAT (WebAssembly Text Format) Inspection

For diagnosing low-level WASM issues, inspect the WAT output:

# Convert WASM binary to WAT
wasm2wat dist/module.wasm -o dist/module.wat

# Check for specific function
grep -n "func \$process_image" dist/module.wat

# Check memory sections
wasm-objdump -x dist/module.wasm | head -100

# Validate WASM is well-formed
wasm-validate dist/module.wasm

Test that your build produces valid WASM:

// tests/build/wasm-validity.test.js
import { execSync } from 'child_process';

describe('WASM Build Validity', () => {
    it('WASM binary is valid', () => {
        const result = execSync('wasm-validate dist/module.wasm', {
            encoding: 'utf8',
            stdio: 'pipe'
        });
        // wasm-validate exits 0 on success with no output
    });
    
    it('WASM exports expected functions', () => {
        const output = execSync('wasm-objdump -x dist/module.wasm', {
            encoding: 'utf8'
        });
        
        const expectedExports = ['process_image', 'sum_array', 'init'];
        for (const fn of expectedExports) {
            expect(output).toContain(fn);
        }
    });
    
    it('WASM binary has reasonable size', () => {
        const stats = require('fs').statSync('dist/module.wasm');
        const sizeMB = stats.size / (1024 * 1024);
        
        // Alert if WASM is unexpectedly large
        expect(sizeMB).toBeLessThan(5); // 5MB limit
    });
});

Monitoring WASM Errors in Production

With HelpMeTest, you can catch WASM runtime errors before users report them:

*** Test Cases ***
WASM Runtime Error Monitor
    [Documentation]    Detect WASM traps and errors in production
    ${errors}=    Create List
    Go To    https://your-app.com
    
    # Trigger the critical WASM operation
    Click Button    css:[data-testid="process-data"]
    Wait For Element    css:[data-testid="result"]    timeout=10s
    
    # Verify no WASM traps occurred
    ${console_errors}=    Get Console Errors
    Should Not Contain    ${console_errors}    WebAssembly.RuntimeError
    Should Not Contain    ${console_errors}    wasm trap
    
    # Verify expected result
    Element Should Not Be Empty    css:[data-testid="result"]

Conclusion

WASM debugging in browsers has matured significantly. With DWARF support in Chrome DevTools and good source map tooling, debugging WASM is now comparable to debugging JavaScript. The key is building debuggability in from the start: compile with debug symbols, generate source maps, add structured error handling, and write tests that capture meaningful error output when things go wrong.

Automated testing for WASM traps, memory leaks, and error propagation catches the most common production issues. Combine these with Playwright's CDP access for capturing console errors in CI, and you have a solid debugging infrastructure that finds problems before your users do.

Start now free