Shipping a JavaScript Fallback for a Wasm Feature

This page answers one task: a feature uses WebAssembly for speed, but must keep working where WebAssembly is unavailable or fails to load — provide a JavaScript implementation with the same interface and choose between the two safely.

Prerequisites

  • [ ] A feature whose core is a function or small set of functions: a checksum, a parser, an image operation.
  • [ ] A Wasm implementation that works, and the ability to write (or reuse) a JavaScript one.
  • [ ] A test suite you can run against both.

When a fallback earns its keep

Every supported browser has WebAssembly, so the question is not whether the API exists but whether the module will load and run in every situation that matters. A few situations still say no. Some enterprise environments disable WebAssembly by policy. Some privacy-focused browser modes disable JIT compilation and, with it, WebAssembly — Safari’s Lockdown Mode is the best-known example. A module may fail to download on a flaky connection, fail to compile on an old engine that lacks a proposal it uses, or fail to instantiate on a low-memory device. And a CSP may forbid compilation.

A fallback is worth maintaining when the feature is essential — a form cannot be submitted without the checksum, a document cannot be opened without the parser — and when a JavaScript implementation is practical. It is not worth it for features that are inherently optional, or when the JavaScript version would be so slow that it is unusable; then a clear “not available” message is the better design. The details of detecting those environments are in degrading gracefully when Wasm is disabled.

Does this feature need a JavaScript fallback? A decision tree. Essential features with a practical JavaScript implementation get a fallback; essential features where JavaScript is too slow get a server-side path or a clear message; optional features simply hide when Wasm is unavailable. Is the feature essential to the page's purpose? essential, JS is practical Ship a JS fallback same interface, shared tests essential, JS too slow Server-side path or a clear explanation optional Hide or disable it no second implementation

Step 1 — define one interface for both

Write the interface first, in terms the rest of the application uses, and make both implementations conform to it. Keep it asynchronous even if the JavaScript version could be synchronous, so callers do not care which one they got:

// checksum.ts
export interface Checksummer {
  readonly kind: "wasm" | "js";
  crc32c(bytes: Uint8Array): Promise<number>;
}
// checksum-js.ts — table-driven CRC-32C in plain JavaScript
const TABLE = (() => {
  const t = new Uint32Array(256);
  for (let n = 0; n < 256; n++) {
    let c = n;
    for (let k = 0; k < 8; k++) c = c & 1 ? 0x82f63b78 ^ (c >>> 1) : c >>> 1;
    t[n] = c >>> 0;
  }
  return t;
})();

export const jsChecksummer: Checksummer = {
  kind: "js",
  async crc32c(bytes) {
    let c = 0xffffffff;
    for (let i = 0; i < bytes.length; i++) c = TABLE[(c ^ bytes[i]) & 0xff] ^ (c >>> 8);
    return (c ^ 0xffffffff) >>> 0;
  },
};
// checksum-wasm.ts
export async function createWasmChecksummer(): Promise<Checksummer> {
  const { default: init, crc32c } = await import("./pkg/checksum.js");
  await init();
  return { kind: "wasm", async crc32c(bytes) { return crc32c(bytes); } };
}

Step 2 — choose at startup, with a real test

Do not decide on the presence of WebAssembly alone; an object that exists can still fail to compile. Try to load the Wasm implementation, verify it on a known input, and fall back if anything goes wrong:

// checksum-loader.ts
let chosen: Promise<Checksummer> | undefined;

export function getChecksummer(): Promise<Checksummer> {
  chosen ??= (async () => {
    if (typeof WebAssembly !== "object") return jsChecksummer;
    try {
      const wasm = await createWasmChecksummer();
      const probe = new TextEncoder().encode("123456789");
      if ((await wasm.crc32c(probe)) !== 0xe3069283) throw new Error("self-test failed");
      return wasm;
    } catch (err) {
      console.warn("checksum: falling back to JavaScript", err);
      return jsChecksummer;
    }
  })();
  return chosen;
}

The self-test catches a broken build, a mismatched glue file, or an engine bug before any real data passes through. It costs microseconds. Record which implementation was chosen — in a log, or in your monitoring — so you know how many users run the fallback.

Choosing an implementation at startup The loader checks that WebAssembly exists, tries to load and instantiate the module, runs a self-test on a known input, and returns the Wasm implementation only if every step succeeds. Any failure returns the JavaScript implementation and records why. WebAssembly exists? typeof check load + instantiate may fail: CSP, network self-test known input → known output Wasm implementation fast path JS implementation on any failure

Step 3 — test both implementations with the same suite

Two implementations of one function drift apart unless the same tests run against both. Parameterize the tests by implementation:

import { describe, it, expect } from "vitest";
import { jsChecksummer } from "./checksum-js";
import { createWasmChecksummer } from "./checksum-wasm";

const impls = { js: async () => jsChecksummer, wasm: createWasmChecksummer };

for (const [name, make] of Object.entries(impls)) {
  describe(`crc32c (${name})`, () => {
    it("matches the standard check value", async () => {
      const c = await make();
      expect(await c.crc32c(new TextEncoder().encode("123456789"))).toBe(0xe3069283);
    });
    it("handles empty input", async () => {
      expect(await (await make()).crc32c(new Uint8Array())).toBe(0);
    });
  });
}

Add a differential test that runs both on random inputs and compares results, which catches edge cases neither suite thought of. The idea is the same as in differential testing Wasm against native builds, applied to the JavaScript fallback.

Step 4 — keep the fallback out of the fast path’s bundle

The JavaScript fallback should not cost Wasm users anything. Import it dynamically, only when needed, so the bundler puts it in its own chunk:

return (await import("./checksum-js")).jsChecksummer;

For small fallbacks the saving is negligible and a static import is simpler. For large ones — a JavaScript port of a codec, a pure-JS image library — dynamic import keeps them out of the main bundle entirely.

Step 5 — make performance expectations explicit

A fallback that works but is ten times slower changes the user experience, and the UI should reflect it: fewer items processed per batch, a progress indicator where the Wasm version needed none, or a warning before a long operation. Measure both implementations on realistic input and design the slow path’s UX deliberately rather than discovering it from user complaints.

Keeping the two implementations honest over time

The usual way fallbacks fail is not at first release but a year later, when the Wasm version has gained a feature and the JavaScript version has not. Treat the interface as the contract and make it impossible to add a capability to one implementation without the other: the shared test suite should fail when a method exists on one but not the other, and code review should treat changes to either implementation as changes to both. When a new feature is genuinely Wasm-only — too expensive to port — make that explicit in the interface, for instance with a capabilities object the caller checks, rather than letting the JavaScript version silently do less.

Document the decision in the code next to the loader: which environments are expected to use the fallback, how fast it is, and what the UI does differently when it is active. That short note saves the next maintainer from rediscovering why two implementations exist.

Expected output

On a normal browser:

checksum implementation: wasm

With WebAssembly disabled, or with the module deliberately broken in a test:

checksum: falling back to JavaScript Error: self-test failed
checksum implementation: js

Both produce identical checksums; the shared test suite passes for both.

Gotchas

  • Choosing on typeof WebAssembly alone. The API exists in environments where compilation is blocked. Load and self-test instead.
  • Fallback tested only manually. Untested fallbacks rot. Run the shared suite in CI, and test the fallback path in a real browser with WebAssembly disabled; see testing fallback paths in CI.
  • Different results on edge cases. Integer overflow, signedness and float rounding differ subtly between Rust or C and JavaScript. Compare on random inputs.
  • Retrying the Wasm path on every call. Decide once and cache the decision; repeated failing loads waste time and bandwidth.

Performance note

For CRC-32C over a 64 MB buffer, the Wasm implementation ran at about 1.1 GB/s in Chrome and the table-driven JavaScript at about 0.45 GB/s — slower, but entirely usable for the form uploads it protected. For a different feature, an image resampler, the JavaScript fallback was nine times slower, which led to a server-side path instead.

Wasm versus JavaScript fallback throughput for two features Throughput of the Wasm implementation and the JavaScript fallback for a CRC-32C checksum and for an image resampler, in Chrome on a laptop. relative throughput (Wasm = 100) CRC-32C, Wasm 100 % CRC-32C, JS fallback 41 % image resampler, Wasm 100 % image resampler, JS fallback 11 %

Frequently Asked Questions

Can I generate the JavaScript fallback from the same source? For C and C++, Emscripten or Binaryen’s wasm2js can compile the module to JavaScript, which avoids maintaining two codebases at a cost in size and speed; see using wasm2js as a fallback.

Can the two implementations share test fixtures? They should. Keep fixtures as plain files that both test suites read, so a bug found in one implementation becomes a test for both.

How many users actually need the fallback? Usually very few, which is why measuring the chosen implementation matters. If almost nobody uses it, a clear message may be the better investment.

Should the fallback run in a worker? Yes, for anything slow — the JavaScript version may take much longer than the Wasm one, and blocking the main thread for it is worse than the original problem.

What about server-side rendering? On the server, use whichever implementation suits the runtime; a Node build can load the Wasm module directly, so the fallback is rarely needed there.

← Back to Polyfill Alternatives & Fallbacks