troubleshooting2025-02-05Β·14Β·211/348

Debugging WebAssembly Compilation Errors in Browser Applications

A systematic approach to diagnosing and fixing WebAssembly compilation errors, memory issues, and performance bottlenecks when using Rust and AssemblyScript targets.

Introduction

WebAssembly promises near-native performance in the browser, but the compilation and runtime errors can be cryptic and difficult to debug. After spending two weeks debugging a WebAssembly module that compiled successfully but crashed at runtime with an unreachable instruction error, I developed a systematic debugging methodology that I now apply to every WASM project.

This post covers the most common WebAssembly compilation errors I have encountered, the tools available for debugging them, and the solutions that worked for my production applications.

Environment

# Rust toolchain for wasm32-unknown-unknown target
rustup target list --installed
# wasm32-unknown-unknown

rustc --version
# rustc 1.75.0 (82e1608df 2023-12-21)

# wasm-pack for building and testing
wasm-pack --version
# wasm-pack 0.12.1

# wasm-bindgen for JavaScript interop
cargo tree | grep wasm-bindgen
# wasm-bindgen v0.2.89

# Node.js for running tests
node --version
# v20.11.0

The project structure:

wasm-project/
β”œβ”€β”€ src/
β”‚   β”œβ”€β”€ lib.rs           # Rust source
β”‚   └── utils.rs         # Helper functions
β”œβ”€β”€ www/
β”‚   β”œβ”€β”€ index.html
β”‚   β”œβ”€β”€ index.js
β”‚   └── package.json
β”œβ”€β”€ tests/
β”‚   └── web.rs           # wasm-pack tests
β”œβ”€β”€ Cargo.toml
└── Makefile

Problem

The WebAssembly module compiled without errors using wasm-pack build, but when loaded in the browser, it threw a runtime error. The error message was minimal and did not indicate which function or memory operation failed.

# Build succeeded
wasm-pack build --target web --release
# [INFO]: wasm pack complete
# [INFO]: use published at: pkg/

# But loading in browser failed
# Console error: RuntimeError: unreachable
#     at wasm-function[42]:0x1a3f

The wasm-function[42] reference is not helpful because the function indices change between builds. The second issue was that debug builds worked fine, but release builds with optimization level 3 crashed. This optimization-dependent behavior made the problem even harder to diagnose.

Additionally, the module's memory usage grew continuously during operation, eventually causing a RangeError: Failed to grow WebAssembly memory. The memory growth limit in browsers is typically 4GB for 32-bit WASM, but the application exceeded this during normal operation.

Analysis

The unreachable instruction in WebAssembly is placed by the compiler when it detects a code path that should never execute. Common causes include overflow checks, assertion failures, and explicit unreachable!() macros in Rust code.

The key insight came from using wasm-objdump to inspect the compiled binary:

# Install wasm-tools
cargo install wasm-tools

# Disassemble the problematic function
wasm-tools print pkg/wasm_project_bg.wasm | grep -A 50 "func \$42"

The disassembly revealed that the compiler had inserted an unreachable instruction after an integer overflow check. The Rust code used u32::try_from() which panics on overflow, and the panic handler in WASM translates to unreachable.

// In lib.rs - This looks safe but causes issues in WASM
pub fn process_index(index: u32, data_len: u32) -> u32 {
    // The try_from conversion panics if the value doesn't fit
    let adjusted = u32::try_from(index as u64 + data_len as u64)
        .unwrap_or(u32::MAX);
    adjusted
}

The memory growth issue was caused by a vector that was never deallocated:

// This vector grows indefinitely
static mut CACHED_RESULTS: Vec = Vec::new();

#[no_mangle]
pub fn add_result(data: &[u8]) {
    unsafe {
        CACHED_RESULTS.extend_from_slice(data);
    }
}

Solution

Fix the integer overflow by using checked arithmetic instead of panicking conversions:

// Before: panics on overflow
pub fn process_index(index: u32, data_len: u32) -> u32 {
    u32::try_from(index as u64 + data_len as u64)
        .unwrap_or(u32::MAX)
}

// After: handles overflow gracefully
pub fn process_index(index: u32, data_len: u32) -> u32 {
    index.wrapping_add(data_len)
}

For the memory growth issue, implement explicit memory management:

use std::sync::Mutex;

// Use a bounded cache with explicit cleanup
struct BoundedCache {
    data: Vec,
    max_size: usize,
}

impl BoundedCache {
    fn new(max_size: usize) -> Self {
        BoundedCache {
            data: Vec::with_capacity(max_size),
            max_size,
        }
    }

    fn add(&mut self, item: &[u8]) {
        if self.data.len() + item.len() > self.max_size {
            // Clear the oldest 25% of the cache
            let clear_amount = self.max_size / 4;
            self.data.drain(..clear_amount);
        }
        self.data.extend_from_slice(item);
    }

    fn clear(&mut self) {
        self.data.clear();
        self.data.shrink_to_fit();
    }
}

static CACHE: Mutex> = Mutex::new(None);

#[no_mangle]
pub fn init_cache(max_size: usize) {
    let mut cache = CACHE.lock().unwrap();
    *cache = Some(BoundedCache::new(max_size));
}

#[no_mangle]
pub fn add_result(data: &[u8]) {
    let mut cache = CACHE.lock().unwrap();
    if let Some(ref mut c) = *cache {
        c.add(data);
    }
}

#[no_mangle]
pub fn free_cache() {
    let mut cache = CACHE.lock().unwrap();
    if let Some(ref mut c) = *cache {
        c.clear();
    }
    *cache = None;
}

Configure the Cargo.toml for optimal WASM builds:

[package]
name = "wasm-project"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib", "rlib"]

[dependencies]
wasm-bindgen = "0.2.89"
js-sys = "0.3.66"
web-sys = { version = "0.3.66", features = [
    "console",
    "Performance",
    "Window",
] }

[profile.release]
opt-level = "z"     # Optimize for size
lto = true          # Link-time optimization
codegen-units = 1   # Better optimization
strip = true        # Remove debug symbols
panic = "abort"     # Smaller binary, no unwinding

Add a Makefile for common build tasks:

.PHONY: build dev test clean

build:
	wasm-pack build --target web --release

build-debug:
	wasm-pack build --target web --dev

test:
	wasm-pack test --headless --chrome

test-node:
	wasm-pack test --node

clean:
	rm -rf pkg/ target/

Enable debug logging during development:

use web_sys::console;

fn log(msg: &str) {
    console::log_1(&msg.into());
}

#[no_mangle]
pub fn process_data(input: &[u8]) -> Vec {
    log(&format!("Processing {} bytes", input.len()));

    let result: Vec = input.iter()
        .map(|b| b.wrapping_add(1))
        .collect();

    log(&format!("Output: {} bytes", result.len()));
    result
}

Lessons Learned

  1. Always test WASM in release mode. Many issues only appear after the compiler applies optimizations that eliminate dead code and reorder instructions.

  2. Use panic = "abort" in release builds to eliminate the panic unwinding infrastructure, which significantly reduces binary size and avoids unreachable instruction errors from panics.

  3. Monitor WASM memory growth in production using the WebAssembly.Memory API. Set up alerts when memory exceeds reasonable thresholds.

  4. wasm-tools is essential for debugging. Install it and learn to use wasm-tools print to disassemble and inspect your compiled WASM binaries.

  5. Build a debug mode that includes logging statements. Use cfg(debug_assertions) to conditionally include debug output that is stripped in release builds.

This blog does not accept any external sponsorships, affiliate marketing, or ad revenue.