Exception Handling in WebAssembly
This guide answers one task: build a module that uses WebAssembly’s native exception handling rather than the JavaScript-based emulation — and understand what changes for code that throws, for code that catches, and for the boundary in between.
Prerequisites
- [ ] Emscripten 3.1.60+ or LLVM 18+ for the C and C++ path.
- [ ] A target engine with the proposal: Chrome 95+, Firefox 100+, Safari 15.2+, Node 18+.
- [ ] Code that actually throws — this changes nothing for code that does not.
- [ ] A fallback build if you support older engines.
What the proposal adds
Before this proposal, WebAssembly had no mechanism for non-local control flow. A C++ throw had to be
emulated: Emscripten rewrote the module so that every call that could throw checked a flag afterwards and
returned early, unwinding by hand through JavaScript.
The proposal adds three things. A tag declares a kind of exception with a payload type. throw
raises one. try/catch blocks catch tags you name, with catch_all for anything, and delegate for
forwarding outward.
(module
(tag $io_error (param i32))
(func $may_fail (param $n i32)
(if (i32.lt_s (local.get $n) (i32.const 0))
(then (throw $io_error (i32.const 42)))))
(func (export "run") (param $n i32) (result i32)
(try (result i32)
(do (call $may_fail (local.get $n)) (i32.const 0))
(catch $io_error (drop) (i32.const -1))
(catch_all (i32.const -2)))))
That is real control flow in the engine rather than a protocol implemented in generated code, which is why it is both faster and smaller.
Building for it
For C and C++, one flag selects native exceptions instead of the emulation.
# native exception handling
emcc app.cpp -fwasm-exceptions -O3 -o app.js
# the emulated fallback, for older targets
emcc app.cpp -fexceptions -O3 -o app-legacy.js
# exceptions disabled entirely — smallest, and breaks any library that throws
emcc app.cpp -fno-exceptions -O3 -o app-noexcept.js
The three produce meaningfully different artifacts. In a representative C++ codebase using exceptions
throughout, -fwasm-exceptions produced a module 18% smaller than -fexceptions and 35% faster on a
workload that threw occasionally — and the difference on the non-throwing path alone was 12%, because the
checks disappear.
For Rust the situation differs: Rust compiles to panic = "abort" in WebAssembly by convention, and its
error handling uses Result rather than unwinding, so this proposal changes nothing for most Rust
modules. Where a crate genuinely needs unwinding, the toolchain support is newer and worth checking
against your compiler version.
Crossing the boundary
An exception that reaches the edge of the module becomes a JavaScript exception, and a JavaScript
exception thrown from an imported function propagates into the module where a catch_all can catch it.
try {
instance.exports.run(-1);
} catch (e) {
// a WebAssembly.Exception for a tagged throw, or a JavaScript error for a trap
if (e instanceof WebAssembly.Exception) {
console.log('tag payload:', e.getArg(tag, 0));
} else {
console.log('trap or JS error:', e);
}
}
WebAssembly.Exception exposes the tag and its payload, which lets JavaScript distinguish a deliberate
throw from a trap — a distinction that matters, since the first is a condition the module reported and the
second means the instance is unusable.
Going the other way, a JavaScript function imported into the module can throw, and the module’s
catch_all will catch it. That is occasionally useful and mostly a hazard: catching a JavaScript
exception inside WebAssembly and continuing usually leaves the JavaScript side in a state the module knows
nothing about.
Tags, and what they carry
A tag is a declared exception type with a parameter list, and it is how a catch block knows what it caught.
(tag $parse_error (param i32 i32)) ;; offset, code
(tag $oom) ;; no payload
Payloads are WebAssembly values, so an exception can carry integers and floats but not a string. C++
exceptions carry a pointer into linear memory pointing at the exception object, which the runtime then
interprets — which is why catching a C++ exception from JavaScript gives you a pointer rather than a
message, and reading the message requires calling back into the module.
For a hand-written interface, keep payloads to integer codes and let the caller map them to messages. That avoids embedding formatting machinery in the module and keeps the payload trivially readable from either side.
Designing an interface that does not need exceptions
Before adopting the proposal, it is worth asking whether the module’s interface should use exceptions at all — as distinct from its internals, where C++ code will throw regardless.
An exported function that throws forces every caller to wrap it, and gives JavaScript a payload it has to interpret. An exported function that returns a status is trivially callable and self-documenting. Most well-designed module interfaces therefore catch at the boundary and return, even when the implementation inside is exception-heavy.
extern "C" int process(const uint8_t* data, size_t len, uint8_t* out, size_t cap) {
try {
return static_cast<int>(run_pipeline(data, len, out, cap));
} catch (const std::bad_alloc&) { return -1; }
catch (const parse_error& e) { return -(100 + e.code()); }
catch (const std::exception&) { return -2; }
catch (...) { return -3; }
}
That boundary function is a few lines and it changes the caller’s experience entirely: a negative return
value is an error code the JavaScript side maps to a message, with no WebAssembly.Exception handling, no
tag inspection and no pointer chasing into linear memory.
Native exception handling is still worth enabling for such a module, because the internals benefit — the per-call checks disappear even though nothing escapes. The proposal’s value does not depend on exceptions crossing the boundary, which is a useful thing to know when deciding whether it is worth a second build.
Expected output
A module built with native exceptions reports a tag section and no emulation helpers:
wasm-objdump -h dist/app.wasm | grep -i tag
# Tag start=0x000002a1 end=0x000002a8 (size=0x00000007) count: 2
wasm-objdump -x dist/app.wasm | grep -c '__cxa_'
# 0 ← emulation helpers absent
# and at runtime
caught WebAssembly.Exception, tag=parse_error, args=[ 128, 3 ]
A build that still shows __cxa_throw and friends in its imports is using the emulation, which usually
means the flag did not apply — a common outcome when flags are set for compilation but not for linking.
Gotchas
- Flag set at compile but not at link. Exception handling is a link-time decision as well; pass it to both.
- Mixing object files built with different exception models. Produces link errors or, worse, a module that half works.
- Catching everything with
catch_all. Catches traps you should not continue after; catch specific tags. - Expecting a message in the payload. Payloads are numbers; a C++ exception carries a pointer.
- Assuming support. Broad but not universal; detect and keep a fallback build if older engines matter.
- Catching a JavaScript exception inside the module. The JavaScript side may be in an inconsistent state the module cannot see.
Performance note
For a C++ codebase where roughly 40% of calls could throw, -fwasm-exceptions produced a module 18%
smaller than -fexceptions, ran the non-throwing path 12% faster, and handled a throw-heavy benchmark
2.4× faster. Against -fno-exceptions — where it compiles at all — the native build was 6% larger and
functionally complete, which is usually the trade worth taking.
Frequently Asked Questions
How do I tell which model a module was built with?
Look for a tag section, which only a native build has, and for __cxa_ imports, which only an emulated
one has. Both checks are one wasm-objdump invocation and worth putting in CI.
Should I disable exceptions instead?
Only if no library you use throws, which in C++ is rarer than it sounds. -fno-exceptions produces the
smallest module and turns any throw into an abort, so it suits a self-contained numeric kernel and
little else.
Does this help Rust?
Rarely. Rust’s WebAssembly convention is to abort on panic and use Result for errors, so there is nothing
for the proposal to improve. It matters for crates that genuinely rely on unwinding.
Does it interact with threads? Exceptions are per-thread, as they are natively: a throw on one thread does not propagate to another, and each thread unwinds its own stack. A worker pool therefore needs its own catch boundary per worker, which is the same discipline any threaded native program uses.
How do I support engines without it? Build twice and select with a capability check, exactly as for any other proposal — see detecting proposal support at runtime.
Related
- Catching wasm traps in JavaScript — the other kind of failure crossing the boundary.
- Handling panics in Rust Wasm — the Rust equivalent of this problem.
- Migrating legacy C code to WebAssembly — where exception support usually first matters.
For a C++ port that throws, this is the single highest-value proposal to adopt, and the flag is one word.
← Back to Post-MVP Wasm Proposals in Practice