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.

Emulation versus native unwinding Emulated exceptions insert a check after every call that might throw, which costs on every call whether or not anything throws. Native exceptions cost nothing on the normal path and unwind in the engine when something does. emulated call check call check a check after every call, always native call call nothing on the normal path; the engine unwinds when a throw happens The saving is proportional to how many calls could throw, which in idiomatic C++ is most of them.

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.

What crosses the boundary A tagged throw that is not caught inside the module surfaces in JavaScript as a WebAssembly.Exception carrying the tag and its integer payload, which the caller can inspect and map to a message. throw $parse_error (offset, code) no matching catch unwinds out of the module WebAssembly.Exception getArg(tag, 0) reads the payload A trap — from a panic, a bad access or an unreachable — arrives as a RuntimeError instead, which is the distinction worth branching on.

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.

What exception handling replaces Before the proposal, a module either returned error codes or crossed the boundary to throw. Native exceptions keep the unwinding inside the module. error code return cheapest every caller must check, and most forget one throw through a JS import a boundary crossing per throw slow native exception handling unwinds in-module zero cost until something actually throws The proposal's value is on the non-throwing path: a try block that never throws costs nothing at all. A thrown exception can still cross into JavaScript, and a JavaScript exception can be caught in the module.

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.

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