Post-MVP Wasm Proposals in Practice
WebAssembly 1.0 was deliberately small: integers, floats, one linear memory, one table, and no way to express anything else. Everything since has been a proposal working through a standards process, and several are now shipped everywhere. This area covers the ones that change how you build rather than how the specification reads — what each enables, what it costs, and how to use it without breaking the browsers that lack it.
Prerequisites
- [ ] A toolchain recent enough to emit the features: LLVM 18+, Emscripten 3.1.60+, Rust 1.82+.
- [ ] A list of the engines you support, with versions.
- [ ]
wasm-validatewith feature flags, for checking what a build actually uses. - [ ] A fallback strategy, because support is never universal on day one.
The proposals that matter to a working engineer
Six have practical consequences today.
Reference types add externref and funcref as first-class values, so a module can hold an opaque
handle to a JavaScript object without inventing an integer table. It underpins much of the modern binding
generation and is supported everywhere.
Bulk memory adds memory.copy and memory.fill, replacing loops with single instructions. Purely a
performance and size win, and universally available.
Exception handling gives WebAssembly real exceptions, which matters enormously for C++ and for any language whose error model is exceptions. Without it, Emscripten emulated them expensively or disabled them.
Tail calls allow a call in tail position to reuse the frame, which functional languages and interpreters need to avoid exhausting the stack.
Garbage collection adds managed heap types the engine collects, so a language with a collector no longer has to ship one. This is the largest of the six in terms of what it changes.
Memory64 raises the address space above 4 GB for modules that genuinely need it.
How a proposal becomes something you can use
Understanding the process removes most of the guesswork about when to adopt something, because each stage means a specific thing.
Proposals move through five phases. Phase 0 and 1 are ideas and explorations, where the design is still changing and nothing is implementable. Phase 2 has a specification text and experimental implementations behind flags. Phase 3 means implementations are converging and the design is stable enough to build against. Phase 4 is standardised, which in practice means every major engine ships it unflagged.
The practical reading is: ignore anything below phase 3, experiment with phase 3 behind a detection check, and treat phase 4 as available subject to your audience’s engine versions. A proposal at phase 3 can still change, and a build depending on it can stop working when an engine updates to match a revised specification — which has happened, and is the reason for the detection check rather than a build-time assumption.
Toolchain support is a separate timeline. A feature can be standardised and unavailable in your compiler, or available in the compiler and not yet in the engine versions your users have. Both gaps are real, and they close at different rates, which is why the answer to “can I use this” always involves checking two things rather than one.
Support is a runtime question
Whether a feature is available depends on the engine, its version and occasionally a flag, and the honest way to find out is to ask at runtime rather than to infer from a user-agent string.
The technique is the same for every proposal: compile a tiny module using the feature and see whether it throws.
function supports(bytes) {
try { new WebAssembly.Module(new Uint8Array(bytes)); return true; }
catch { return false; }
}
// a minimal module using a feature, as a byte array
const TAIL_CALL = [0x00,0x61,0x73,0x6d, 0x01,0x00,0x00,0x00, /* … */];
const hasTailCall = supports(TAIL_CALL);
The wasm-feature-detect package packages one of these per proposal and is the practical choice, as
covered in
detecting proposal support at runtime.
Run the checks once at startup, cache the result for the session, and choose which build to load.
The cost of adopting one
A proposal changes three things, and all three need a decision.
The build must emit it, which usually means a compiler flag and occasionally a newer toolchain. That is the easy part.
The audience narrows to engines that support it. For a universally available proposal that is no narrowing at all; for a recent one it can be a meaningful fraction of traffic, and the only way to know is to measure your own.
The fallback has to exist and be maintained. Shipping two builds doubles the build matrix and the testing, which is a real cost — and is why adopting a proposal is worth doing when it buys something substantial rather than because it is new.
# build twice, validate each against what it is allowed to use
wasm-validate --enable-bulk-memory --enable-reference-types dist/engine.baseline.wasm
wasm-validate --enable-bulk-memory --enable-reference-types --enable-tail-call \
--enable-exceptions dist/engine.modern.wasm
Shipping two builds without doubling the work
Where a proposal is worth adopting before it is universal, the mechanics of shipping both builds are worth getting right once and then reusing.
Build both from the same source with different flags, so there is one implementation and two artifacts. Name them by capability rather than by version, so the loader’s logic reads clearly. Validate each against exactly the features it is allowed to use, which catches the baseline build silently acquiring an instruction it must not have.
# one source, two artifacts, each validated against its own feature set
cargo build --profile web --target wasm32-unknown-unknown
wasm-opt -Oz -o dist/engine.baseline.wasm target/.../engine.wasm
RUSTFLAGS="-C target-feature=+tail-call,+exception-handling" cargo build --profile web --target wasm32-unknown-unknown
wasm-opt -Oz --enable-tail-call -o dist/engine.modern.wasm target/.../engine.wasm
wasm-validate dist/engine.baseline.wasm
wasm-validate --enable-tail-call --enable-exceptions dist/engine.modern.wasm
On the loading side, detect once and choose:
const caps = await detectCapabilities(); // cached for the session
const url = caps.tailCall && caps.exceptions
? '/assets/engine.modern.a91c3f.wasm'
: '/assets/engine.baseline.a91c3f.wasm';
Report which build each session loaded. That number is what tells you when the baseline can be retired, which is the decision the whole arrangement exists to enable — and without the telemetry it never gets made, so the second build lives forever.
What garbage collection changes
Of the six, this is the one that changes the economics rather than the ergonomics. A language with a managed heap has, until now, had to compile its collector into the module — which is why a Go or .NET module starts at a megabyte and a Rust module starts at ten kilobytes.
With WebAssembly GC, the engine’s own collector manages the objects, so the language ships its semantics rather than its runtime. Languages adopting it — Kotlin, Dart, Java, Scheme implementations — produce dramatically smaller modules than they could before.
It is not retrofittable. A language with a deeply integrated collector and a linear-memory object model cannot simply switch, which is why Go and .NET are not on that list. And it does not help a language that had no collector to begin with: Rust and C gain nothing.
Exception handling and the C++ story
For C++ this is the proposal that removes a long-standing tax. Before it, Emscripten either disabled exceptions — which breaks any library that throws — or emulated them with JavaScript, which was correct and slow enough to affect the whole module’s performance.
# emulated, the old way: correct and expensive
emcc app.cpp -fexceptions -o app.js
# native, using the proposal
emcc app.cpp -fwasm-exceptions -o app.js
The native version is typically 30–50% faster on exception-heavy code and smaller, because the emulation’s
bookkeeping disappears. Support is now broad enough that -fwasm-exceptions is the default choice for new
builds, with the emulated path as a fallback for older targets.
Tail calls, and who needs them
A tail call reuses the caller’s frame instead of adding one, so a function that calls itself in tail position runs in constant stack space. Without it, such a function exhausts the stack and traps.
That matters for compilers targeting WebAssembly from functional languages, for interpreters written as mutually recursive dispatch loops, and for any algorithm expressed recursively over a large input. For ordinary imperative code it changes nothing, which is why it went unnoticed by most users until interpreters started appearing.
Proposals that are still ahead
Several proposals are worth knowing about even though they are not yet something to build on, because they change what WebAssembly will be able to do and therefore what is worth designing for.
Stack switching gives modules the ability to suspend and resume computations, which is what makes asynchronous code in a synchronous language work without the expensive rewriting that Asyncify performs today. For any language whose concurrency model assumes coroutines, this is the missing piece.
Multi-memory lets a module hold more than one linear memory, which makes it possible to separate untrusted data from module state, or to map several distinct regions without arithmetic tricks. It is particularly relevant to plugin hosts and to languages with separate heaps.
Relaxed SIMD offers instructions whose exact results may vary between engines in exchange for substantially better performance on operations that hardware implements differently. That non-determinism is the point and the caveat: excellent for graphics and inference, unacceptable where two nodes must agree.
Threads beyond shared memory — including improvements to atomics and to how threads are spawned — continue to develop, and the browser’s requirement for cross-origin isolation remains the practical constraint rather than the specification.
None of these should influence a decision today, and all of them are worth reading about before committing to a workaround that one of them will make unnecessary in a couple of years.
Deciding whether to adopt
A short checklist keeps the decision proportionate.
Does the proposal remove a real cost — a shipped collector, an exception emulation, a stack limit — or does it merely make the code tidier? Only the first justifies a second build.
Is support broad enough in your measured audience? Not in general, but among your users, which you can find out with a detection check reported to telemetry before you ship anything that depends on it.
Is the fallback cheap? Shipping a baseline build alongside is straightforward when the difference is a compiler flag, and expensive when it means a different implementation.
If all three answers are favourable, adopt. If not, the feature will be universally available in a year or two and the decision becomes free.
Server-side runtimes move at a different pace
Everything above assumes a browser, where the engine is the user’s. On a server the engine is yours, which changes the calculus completely.
A standalone runtime you deploy can be upgraded on your schedule, so a proposal implemented in Wasmtime is available to you as soon as you update — no detection, no fallback, no second build. That makes adoption a straightforward dependency decision rather than an audience question.
The catch is that runtimes implement proposals at different rates and behind different flags, so a module depending on a recent feature ties you to a runtime version and sometimes to a specific runtime. If that module is also meant to run in a browser, it is constrained by the slower of the two environments.
# a runtime enables features explicitly, so the capability is visible in the invocation
wasmtime run --wasm tail-call=y --wasm gc=y module.wasm
The practical pattern for code shared between a browser and a server is to target the browser’s capabilities for the shared core and use the server’s extra capabilities only in server-specific code. That keeps one module portable while letting the server-side parts use whatever the runtime offers, which is usually where the demanding work is anyway.
Gotchas and failure modes
- Assuming support from a browser version. Flags, platform differences and enterprise policies all intervene. Detect at runtime.
- A baseline build that quietly uses a proposal. Validate it without the feature enabled, so an accidental instruction fails the build.
- Detection run per call. Compiling a probe module is not free; run it once and cache.
- Adopting for tidiness. Every proposal adopted early costs a fallback path someone maintains.
- Forgetting the toolchain side. A feature the engine supports still needs a compiler that emits it, and the flags differ between toolchains.
- Treating the fallback as untested. Route a fraction of traffic through it deliberately.
Verifying a feature is actually in use
Emitting a feature and using it are different things, and wasm-objdump settles the question:
# tail call instructions are return_call / return_call_indirect
wasm-objdump -d dist/engine.modern.wasm | grep -c 'return_call'
# 42
# exception handling shows a tag section
wasm-objdump -h dist/engine.modern.wasm | grep -i tag
# Tag start=0x000001f4 end=0x000001f9 (size=0x00000005) count: 1
If the count is zero, the flag did not take effect and you are shipping a second build identical to the first — which happens more often than it should, and which a single check in CI prevents permanently.
Guides in this topic
- Using Wasm GC for managed languages — what the engine’s collector changes.
- Exception handling in WebAssembly — native throw and catch, and the C++ flags.
- Tail calls and deep recursion — constant-space recursion in practice.
- Reference types and externref — holding host objects without a table of integers.
- Memory64 and large heaps — past 4 GB, and what it costs.
- Detecting proposal support at runtime — the probe modules and how to use them.
Frequently Asked Questions
How do I know when a proposal is safe to require? When your own telemetry shows detection succeeding for effectively all traffic. Published support tables are a starting point and your users are the answer.
Do these proposals change the security model? No. Every one of them operates inside the same sandbox with the same capability model. Garbage collection adds engine-managed objects, which are still unreachable from outside the instance.
Will the component model make these irrelevant? No — it builds on them. Reference types and garbage collection in particular are what make the component model’s type system implementable, so the proposals here are the foundation rather than a parallel track.
What happened to the SIMD proposal? It shipped and is now baseline in every current engine, which is why it has its own area on this site rather than appearing here as a proposal.
Should I enable every feature my engine supports? No. Enable what the code actually uses, and validate against exactly that set. A build permitted to use everything will eventually use something you did not intend, and the first you hear of it will be from a user on an older device.
Where do I find the current status of a proposal? The proposals repository tracks the phase, and each engine publishes its own support. Both are useful background, and neither substitutes for a detection check in your own code against your own traffic.
Related
- Wasm SIMD & Vectorized Computation — the proposal that shipped first and changed the most.
- Tables & Dynamic Linking — what reference types build on.
- Wasm Component Model & WIT Bindings — where these proposals are heading.
The pattern that holds across all of them: detect rather than assume, validate what each build is allowed to use, and retire the fallback on evidence.