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.

The same module as Wasm and as wasm2js output The Wasm module is compact, streams and compiles quickly, and runs at near-native speed. The wasm2js translation needs no WebAssembly support at all but is larger, parses as JavaScript and runs slower, especially for 64-bit integer code. WebAssembly module compact bytecode streaming compilation native i64, SIMD, threads the fast path wasm2js output JavaScript, 2-4× larger parsed like any script i64 emulated; no SIMD or threads works where Wasm cannot

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.

What wasm2js can and cannot translate WebAssembly features and whether wasm2js translates them, with the cost or workaround for each. feature wasm2js note i32 / f32 / f64 arithmetic yes fast in modern engines i64 arithmetic emulated pairs of i32, much slower memory, tables, globals yes typed arrays and arrays SIMD (v128) no build a scalar variant threads, atomics no single-threaded build only exceptions, GC, memory64 no MVP-only build

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. -O2 was 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 i64 run 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.

Parsing a corpus with the Wasm build and the wasm2js build Time to parse the same test corpus with the WebAssembly build in Chrome, the wasm2js build in Chrome, and the wasm2js build in Safari with Lockdown Mode, where WebAssembly and the JavaScript JIT are disabled. ms to parse the corpus Wasm, Chrome 120 ms wasm2js, Chrome 276 ms wasm2js, Safari Lockdown Mode 2,280 ms

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.

← Back to Polyfill Alternatives & Fallbacks