Fixing Traps That Appear After a Dependency Upgrade

This page answers one task: after updating Rust, Emscripten, Binaryen, wasm-bindgen or a library, a module that used to work now traps — usually RuntimeError: unreachable, sometimes an out-of-bounds access or a new CompileError in some browsers — and you need to find which change caused it and fix it without reverting everything.

Prerequisites

  • [ ] The last known-good commit and the first known-bad commit.
  • [ ] A reproducing test case — ideally an automated test.
  • [ ] The ability to rebuild old versions: pinned toolchains, committed lockfiles.

Why upgrades break working modules

A WebAssembly build involves more moving parts than most: the compiler (rustc or clang), its standard library, the linker, the bindings generator, the optimiser (wasm-opt), each library dependency, and the engines that run the result. An upgrade can change behaviour in several distinct ways. A library can introduce a real bug or a new panic on inputs it used to accept. A compiler can change which target features are enabled by default — Rust 1.82, for example, enabled several post-MVP features (including reference types and multi-value) for wasm32-unknown-unknown by default, so binaries started using instructions older engines reject. An optimiser can expose undefined behaviour in C or unsafe Rust that the previous version happened to compile benignly. And a bindings generator can change glue conventions. The symptom — a trap — is the same for all of them, so the first job is to separate them.

Classifying a post-upgrade failure If the module now fails to compile in some browsers, a new default target feature is likely. If it traps with unreachable and a panic message, a library behaviour changed. If it traps only in optimised builds, undefined behaviour exposed by the optimiser is likely. Bisecting tells you which upgrade to examine. What changed in the failure? CompileError in some browsers new default target features unreachable with a panic message library behaviour or new panic traps only in release builds UB exposed by optimisation

Step 1 — capture the panic message and stack

Before bisecting, make the failure informative. With console_error_panic_hook installed in Rust, an unreachable trap is preceded by the panic message and location — “called Option::unwrap() on a None value at …/some-crate-0.9.3/src/lib.rs:210” points straight at a dependency. For C/C++, rebuild with -sASSERTIONS=2 and debug information. A stack trace with names shows whether the failing frames are in your code, a dependency or the standard library, as described in debugging unreachable-executed traps.

Step 2 — bisect the upgrade

If the upgrade touched many things at once — a cargo update that bumped forty crates, plus a new toolchain — bisect it. Restore the good lockfile and toolchain, then apply changes in halves until one change reproduces the failure:

git checkout good -- Cargo.lock rust-toolchain.toml
cargo update -p serde -p serde_json        # apply a subset of the updates
cargo test --target wasm32-unknown-unknown # or run the reproducing browser test

For a single crate with many intermediate versions, cargo update -p crate --precise X.Y.Z lets you step through releases. For toolchain changes, keep the lockfile fixed and switch only rust-toolchain.toml or the emsdk version. Pinned toolchains, as in pinning Wasm toolchain versions, are what make this possible.

Step 3 — compare the binaries

When the culprit is a toolchain component, compare what changed in the output:

wasm-tools print good.wasm > good.wat && wasm-tools print bad.wasm > bad.wat
wasm-tools validate --features=mvp bad.wasm   # fails if new features are now used
diff <(wasm-objdump -x good.wasm | grep -A40 'Type\[') <(wasm-objdump -x bad.wasm | grep -A40 'Type\[')

Look for new proposal instructions (ref.null, return_call, try_table, bulk-memory ops), changed imports or exports, and different section sizes. A new instruction in a module that must run in older browsers explains a CompileError there; restore compatibility by targeting a lower feature level — for Rust, -C target-cpu=mvp or explicit -C target-feature=-reference-types,-multivalue with build-std where needed — or by shipping a separate baseline build.

Behaviour changes versus build output changes A library behaviour change produces the same kind of binary but a new panic or different results, found by bisecting crates. A toolchain output change produces new instructions, imports or sizes, found by comparing binaries and validating against an older feature set. library behaviour changed new panic or different results binary shape similar bisect with cargo update -p fix input handling or pin build output changed new instructions or imports fails in older engines compare WAT and validate adjust target features

Step 4 — check for exposed undefined behaviour

If the failure appears only with optimisations, or only after a wasm-opt or compiler upgrade, suspect undefined behaviour in your code or in C dependencies: out-of-bounds writes, uninitialised reads, signed overflow, aliasing violations in unsafe Rust. Newer optimisers exploit such UB more aggressively. Run the test suite with sanitizers (AddressSanitizer and UBSan in Emscripten builds, Miri for unsafe Rust run natively) on the old and new versions; the bug usually reproduces in both once instrumented, proving the upgrade only revealed it. Fix the UB rather than downgrading.

Step 5 — prevent the next surprise

Upgrade one component at a time, in separate commits, with the full test suite — including a run in the oldest browser you support — gating each. Keep a small set of golden outputs (decoded images, parsed documents) and compare them after upgrades to catch behaviour changes that do not trap. Record each toolchain version in the build output (a custom section or a version() export) so production reports show which toolchain built the failing module.

Reading changelogs efficiently

Changelogs for compilers and Wasm tooling are long, and only a few kinds of entry matter for “it worked yesterday”. Search them for these terms: “target feature”, “default”, “wasm32”, “panic”, “unreachable”, “allocator”, “breaking”, “MSRV” and the names of proposals (bulk memory, reference types, multi-value, exceptions, tail calls). For Emscripten, the ChangeLog lists changed setting defaults prominently; new defaults for ALLOW_MEMORY_GROWTH, EXPORT_ES6, stack size or exception handling have broken many builds. For wasm-bindgen, look for changes to generated glue, init signatures and reference-types output. For Binaryen, look for new passes enabled at -O levels. Reading these sections for every upgrade takes minutes and often explains a failure before any bisecting is needed. Keep notes of what you found in the upgrade commit message, so the next person who bisects lands on an explanation rather than a bare version bump.

When to pin and wait

Not every regression is yours to fix. If bisection points at a genuine bug in a dependency or the toolchain, pin the last good version, write a minimal reproduction, and report it upstream with the binary diff or panic message. A minimal reproduction — a few lines of Rust or C plus the exact versions and flags — is far more likely to get a quick fix than a description of a large application. Add a test that will fail when the pin is lifted if the bug is still present, and a reminder to revisit the pin, so the workaround does not quietly outlive the problem.

Engine upgrades are upgrades too

Sometimes nothing in your build changed and the module still breaks: a browser release changed behaviour. Engine regressions are rare but real, and they show up as failures in one browser version only, often on a specific platform. Check the browser version in error reports, reproduce with that exact version (Chrome Canary, Firefox Nightly and Safari Technology Preview make it easy to test upcoming releases), and confirm with the same binary in another engine. If the module works elsewhere and the failure appeared with the browser update, reduce it to a minimal module — wasm-tools shrink or manual reduction of the WAT — and file a bug with the engine. In the meantime, a workaround in your build, such as disabling a feature for that engine, keeps users working. Running your browser tests against beta and nightly channels in a non-blocking CI job catches such regressions before they reach users.

Expected output

Bisection identifies a single crate bump that introduced a new unwrap on an optional field; handling the None case fixes the trap; the binary comparison shows no new instructions; and the upgrade lands with a regression test containing the triggering input.

Gotchas

  • Upgrading everything at once. Bisection becomes slow. Upgrade components separately.
  • Uncommitted lockfiles. You cannot rebuild the good version. Commit Cargo.lock and pin toolchains.
  • New default target features. Older browsers reject the module. Validate against your minimum feature set.
  • Downgrading to hide UB. The bug remains. Fix it with sanitizers’ help.
  • Ignoring panic messages. They usually name the crate and line. Install a panic hook.

Performance note

Bisecting a 40-crate cargo update took six build-and-test rounds, about 9 minutes, using a cached target directory. Comparing binaries with wasm-tools print and diff took seconds and immediately showed whether new instructions were involved.

Time to isolate the breaking change Minutes to identify which change broke the module when reverting and retrying changes by hand, bisecting the lockfile in halves, and bisecting with a cached target directory. minutes to isolate the cause manual trial and error 70 min bisect lockfile in halves 22 min bisect with cached target dir 9 min

Frequently Asked Questions

How do I know if a new Rust version changed Wasm features? Check the release notes for wasm32 target changes, and compare wasm-tools validate --features=mvp results before and after.

Can I keep the new toolchain but old feature defaults? Yes — disable specific target features with -C target-feature=-feature and rebuild the standard library if required.

What if the trap only happens in one browser? It may be an engine bug or a feature difference. Test the same binary in other engines and report minimal reproductions.

Should I run CI against nightly toolchains? A non-blocking nightly job gives early warning of upcoming breakage without blocking releases.

Can a browser update break a module I did not change? Occasionally. Confirm with another engine and the beta channels, reduce the module, and report the regression.

← Back to Troubleshooting Common Wasm Errors