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-unknownThis 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.jsAssemblyScript
# --sourceMap generates source maps
asc assembly/index.ts --outFile dist/module.wasm --sourceMapChrome DevTools WASM Debugging
Setting Up the C/C++ DevTools Extension
- Install the C/C++ DevTools Support extension from the Chrome Web Store
- Open DevTools → Settings → Experiments → Enable "WebAssembly Debugging: Enable DWARF support"
- 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:
- Open Sources panel → find the
.wasmfile (it appears as disassembled WAT) - You'll see something like:
(func $process_image (param i32 i32 i32)
local.get 0
local.get 1
i32.mul
...)- 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 addressProgrammatic 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.wasmTest 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.