Using wasm2js as a Fallback
This page answers one question: can you avoid writing a separate JavaScript fallback by compiling the WebAssembly module itself into JavaScript — and what does that cost?
Prerequisites
- [ ] Binaryen (for
wasm2js) or emsdk (for-sWASM=0). - [ ] A module that uses mostly MVP WebAssembly features — integer and float arithmetic, memory, tables.
- [ ] A way to measure size and speed of both variants on a target device.
How a module becomes JavaScript
wasm2js is a Binaryen tool that translates a WebAssembly module into an equivalent JavaScript module. Linear memory becomes an
ArrayBuffer with typed-array views; each Wasm function becomes a JavaScript function; integer arithmetic uses | 0 and Math.imul to
reproduce 32-bit wraparound; loads and stores become typed-array accesses. The output is close in spirit to asm.js, the JavaScript subset
that preceded WebAssembly, and modern JavaScript engines optimize it reasonably well.
The appeal is obvious: one source, one build, two outputs, guaranteed identical behaviour. The costs are real too. JavaScript text is larger than Wasm bytecode, even after compression. It must be parsed and compiled as JavaScript, which is slower than streaming Wasm compilation. It runs slower, particularly for 64-bit integer arithmetic, which JavaScript lacks natively, and it cannot express some newer features at all. So wasm2js is a fallback that trades efficiency for not maintaining a second implementation.
Step 1 — translate a module with wasm2js
wasm2js app.wasm -O2 -o app.wasm.js
ls -la app.wasm app.wasm.js
The output is an ES module that exports the same functions and memory the Wasm module exports. Imports become parameters of a function the module exports, so the host provides them the same way it would provide an import object:
import { instantiate } from "./app.wasm.js";
const exports = instantiate({ env: { log: (x) => console.log(x) } });
console.log(exports.add(2, 3)); // same API as instance.exports
-O2 runs Binaryen’s optimizer on the translated code, which shrinks the output considerably; always use it for shipping builds.
Step 2 — or let Emscripten produce it
Emscripten can produce a JavaScript-only build of a C or C++ program directly:
emcc -O2 src/*.c -sWASM=0 -sMODULARIZE -sEXPORT_ES6 -o dist/app.fallback.mjs
emcc -O2 src/*.c -sMODULARIZE -sEXPORT_ES6 -o dist/app.mjs
-sWASM=0 runs wasm2js internally and emits a loader with the same factory interface as the normal build, so the application chooses one
factory or the other and the rest of the code is unchanged. Emscripten also offers -sWASM=2, which emits both and picks at runtime — a
single loader that tries WebAssembly and falls back to the JavaScript build.
Step 3 — check what will not translate
wasm2js supports the MVP feature set and a few extensions. It cannot translate SIMD instructions, threads and atomics, exception handling using the newer proposal, GC types, or memory64. A module built with any of those either fails to translate or needs a separate build without them:
wasm2js simd_build.wasm -o out.js
Fatal: wasm2js does not support SIMD (v128) operations
For modules that use SIMD, build a second, scalar variant for the fallback — the same idea as shipping a baseline build alongside a SIMD
one, covered in shipping SIMD and baseline builds together.
For Rust, disable the features in the fallback build with -C target-feature=-simd128 and run wasm2js on that output.
Step 4 — load it only when needed
The translated module is large; make sure Wasm-capable browsers never download it. Choose at startup and import dynamically:
async function loadEngine() {
try {
const { default: create } = await import("./dist/app.mjs");
const m = await create();
if (m._self_test() !== 42) throw new Error("self-test failed");
return m;
} catch (err) {
console.warn("using wasm2js fallback", err);
const { default: createFallback } = await import("./dist/app.fallback.mjs");
return createFallback();
}
}
Because both builds come from the same source and expose the same interface, the rest of the application does not change. The self-test pattern is the same as in shipping a JavaScript fallback for a Wasm feature.
Step 5 — measure on a device without WebAssembly
The environments that need this fallback — locked-down browsers, JIT-less modes — often also lack a fast JavaScript JIT. That matters a lot: wasm2js output depends on the JavaScript JIT to perform tolerably, and in a JIT-less browser it runs in the interpreter, many times slower than in a normal browser. Measure where the fallback will actually run. If the result is unusable there, a server-side path or a clear message serves those users better than a fallback that technically works and practically hangs.
Keeping the fallback build in the pipeline
A fallback that is only built when someone remembers to build it will be broken when it is needed. Make the wasm2js output part of every release build, produced from the same commit and the same toolchain versions as the main module, and run the same tests against it. The cost is a few extra seconds of build time; the benefit is that the fallback is always in a known state.
Size it like any other asset. Add the fallback file to your size budget so a change that doubles it — a new large data table, an accidentally included debug build — is noticed in review rather than by the few users who download it. And version it with the same content hash scheme as the main module, so caches treat it the same way and a stale fallback never pairs with new JavaScript.
Finally, record in monitoring how often the fallback is chosen and why — disabled WebAssembly, failed compilation, failed self-test. Those numbers decide whether the fallback is worth keeping: a fallback used by no one for a year is a maintenance cost with no benefit, while one used by a steady share of enterprise users is an important part of the product that deserves its own performance attention.
When generated JavaScript is the right call
wasm2js fits a specific niche well: modest-sized modules of mostly 32-bit integer and float code, where behaviour must match exactly and a second hand-written implementation would be a maintenance burden. Parsers, validators, small codecs and geometric computations are good examples. It fits poorly for large applications, where the JavaScript output becomes megabytes, for code dominated by 64-bit arithmetic — hashes, crypto, bit manipulation — where emulation is slow, and for anything that depends on SIMD or threads for acceptable speed. In those cases a smaller, purpose-written JavaScript fallback, or no fallback, is usually better.
Expected output
For a small parser module:
app.wasm 142 KB (48 KB brotli)
app.wasm.js (-O2) 388 KB (91 KB brotli)
and the same test suite passes against both builds.
Gotchas
- The translated module is huge.
-O2was omitted, or the module contains a large data segment, which wasm2js embeds as a string. Optimize, and consider loading large data separately. - Slow 64-bit code. Hashes and bit manipulation using
i64run several times slower. Profile and, if needed, rewrite hot functions to use 32-bit operations in the fallback build. - Translation fails on a feature. The module uses SIMD, threads or another post-MVP feature. Build an MVP-only variant for the fallback.
- Both builds downloaded. A static import pulled the fallback into the main bundle. Import it dynamically.
Performance note
For the parser above in Chrome, the wasm2js build parsed a test corpus 2.3 times slower than the Wasm build and took 2.1 times longer from download start to ready. In Safari with Lockdown Mode — where WebAssembly is disabled and the JavaScript JIT is too — the wasm2js build was the only option and ran 19 times slower than Wasm in a normal Safari, which was still fast enough for the form validation it powered.
Frequently Asked Questions
Does the fallback need its own CSP rules?
No — it is ordinary JavaScript, so it runs under script-src without 'wasm-unsafe-eval'. That is one reason it works in environments
where policy forbids WebAssembly compilation.
Is asm.js still relevant? Not as a target to write by hand. wasm2js produces asm.js-like output, which is why it optimizes reasonably in modern engines, but there is no reason to target asm.js directly.
Can Rust use wasm2js?
Yes — run wasm2js on the Rust module. wasm-bindgen glue expects a real WebAssembly.Instance, so the translated module needs adapter code;
it works best for modules with a simple numeric interface.
Does the fallback support source maps? Binaryen can emit source maps for wasm2js output, which makes debugging the fallback path practical.
How do I test the fallback? Run the shared test suite against both builds in CI, and run the page with WebAssembly disabled; see testing fallback paths in CI.
Related
- Degrading gracefully when Wasm is disabled — where this fallback actually runs.
- Reducing Wasm bundle size with wasm-opt — Binaryen’s other main tool.
- Detecting proposal support at runtime — choosing between feature-specific builds.
- Emitting ES modules from Emscripten — the factory interface both builds share.
← Back to Polyfill Alternatives & Fallbacks