Polyfill Alternatives & Fallbacks
WebAssembly itself ships in every browser released since 2017, but the features layered on top of it — SIMD, threads, bulk-memory, reference-types — arrived on staggered timelines and are still missing from older or locked-down clients. A module compiled with +simd128 will fail to instantiate on an engine that does not implement it, and the failure is a flat CompileError long after your page has loaded. This guide covers how to probe for both base support and individual proposals, and how to fall back — to asm.js, to a transpiled JavaScript build, or to a slower pure-JS path — without crashing the page.
Prerequisites
- [ ] A modern build of
node(18+) for testing fallback paths headlessly. - [ ] A
.wasmartifact and, optionally, awasm2js-transpiledasm.jsbuild of the same module. - [ ] Browsers or emulated clients spanning your support matrix (Chrome 57+, Firefox 52+, Safari 11+, Edge 16+ for base Wasm; newer for SIMD/threads).
- [ ] For threads testing: a server that can send
Cross-Origin-Opener-PolicyandCross-Origin-Embedder-Policyheaders. - [ ]
binaryeninstalled (wasm2js,wasm-opt) if you intend to generate a JS fallback from your.wasm.
The detection-to-load decision flow
The mistake that breaks production is treating “WebAssembly exists” as a single boolean. There are really three layered questions, and you must answer them top-down before you pick which artifact to load:
- Is the
WebAssemblyobject present at all? (typeof WebAssembly === "object".) - Does it support the proposal your module was compiled against? A SIMD build needs a SIMD-capable engine; checking only the base object lets a SIMD
CompileErrorslip through. - Does instantiation actually succeed at runtime? Even with the right features, a corporate proxy stripping the
application/wasmMIME type, a CSP withoutwasm-unsafe-eval, or a memory limit can still reject the load — so the final guard is atry/catcharound instantiation.
The decision flow below threads those three checks into a single load path that always terminates in a working module, whether that is the optimized SIMD build, a baseline .wasm, or a JavaScript fallback.
This is the same three-stage gate that the Wasm instantiation lifecycle formalizes — validate, compile, instantiate — except here every stage has an escape hatch to a fallback instead of an unhandled rejection.
Step-by-step workflow
1. Detect base support
The cheapest check is a pair of typeof guards. Test both the namespace object and the instantiate function, because a handful of very old or stubbed environments expose one without the other.
const hasWasm =
typeof WebAssembly === "object" &&
typeof WebAssembly.instantiate === "function";
2. Probe individual proposals with WebAssembly.validate
WebAssembly.validate takes a BufferSource and returns a boolean synchronously — no instantiation, no side effects. The standard technique is to embed the smallest possible module that uses the feature and validate it. If the engine rejects the bytes, the feature is unavailable. These minimal probes are exactly what wasm-feature-detect ships; here are the bytes for SIMD and bulk-memory inline so you can see the mechanism.
// A 1-page module whose only body is `v128.const 0; drop` — invalid without SIMD.
const SIMD_PROBE = new Uint8Array([
0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, // magic + version
0x01, 0x04, 0x01, 0x60, 0x00, 0x00, // type: () -> ()
0x03, 0x02, 0x01, 0x00, // func 0 has type 0
0x0a, 0x0a, 0x01, 0x08, 0x00, // code section, body len 8
0xfd, 0x0c, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, // v128.const (truncated marker)
0x1a, 0x0b, // drop; end
]);
const supportsSimd = () => WebAssembly.validate(SIMD_PROBE);
In practice you should not hand-maintain those byte arrays — pull them from a maintained library:
npm install wasm-feature-detect
import { simd, threads, bulkMemory, referenceTypes } from "wasm-feature-detect";
const features = {
simd: await simd(),
threads: await threads(), // also requires SharedArrayBuffer + crossOriginIsolated
bulkMemory: await bulkMemory(),
refTypes: await referenceTypes(),
};
Note that threads() returns true only when SharedArrayBuffer exists and the document is cross-origin isolated, which depends on COOP/COEP headers being served — a build concern covered under Wasm optimization flags & size reduction, since which features you compile in determines which probes you must run.
3. Choose the artifact
Map the detected feature set to a concrete URL. Ship a SIMD build for engines that support it, a baseline .wasm for the rest, and a JavaScript build for the no-Wasm tail.
function pickArtifact(features) {
if (!hasWasm) return { kind: "js", url: "/dist/module.asm.js" };
if (features.simd) return { kind: "wasm", url: "/dist/module.simd.wasm" };
return { kind: "wasm", url: "/dist/module.baseline.wasm" };
}
4. Generate the JavaScript fallback
For the no-Wasm path you need an actual JavaScript implementation. The lowest-effort option is wasm2js, which transpiles a .wasm into asm.js-flavored JavaScript that runs anywhere a script tag runs.
# Transpile the baseline module to asm.js-style JS
wasm2js module.baseline.wasm -o module.asm.js
# Optionally minify the (large) output
wasm-opt module.baseline.wasm --emit-text -o /dev/null # sanity-check it validates first
5. Load with a runtime guard
Wrap instantiation in try/catch so a MIME, CSP, or memory failure still resolves to the fallback rather than rejecting.
async function loadModule(importObject = {}) {
const choice = pickArtifact(features);
if (choice.kind === "js") {
return (await import(choice.url)).default;
}
try {
const { instance } = await WebAssembly.instantiateStreaming(
fetch(choice.url),
importObject
);
return instance.exports;
} catch (err) {
console.warn("streaming failed, retrying buffered:", err);
const bytes = await (await fetch(choice.url)).arrayBuffer();
const { instance } = await WebAssembly.instantiate(bytes, importObject);
return instance.exports;
}
}
Feature detection plus conditional load: the full pattern
Putting detection and loading together gives one function that any caller can await. It probes once, caches the result, picks the artifact, and degrades through three layers — streaming Wasm, buffered Wasm, then JavaScript — before giving up.
import { simd } from "wasm-feature-detect";
const hasWasm =
typeof WebAssembly === "object" &&
typeof WebAssembly.instantiate === "function";
let cachedExports;
export async function getEngine(importObject = {}) {
if (cachedExports) return cachedExports;
// No Wasm at all → straight to the JS implementation.
if (!hasWasm) {
cachedExports = (await import("/dist/module.asm.js")).default;
return cachedExports;
}
// Probe the proposal we compiled against and choose the artifact.
const url = (await simd())
? "/dist/module.simd.wasm"
: "/dist/module.baseline.wasm";
try {
const { instance } = await WebAssembly.instantiateStreaming(
fetch(url, { headers: { Accept: "application/wasm" } }),
importObject
);
cachedExports = instance.exports;
} catch (streamErr) {
try {
const bytes = await (await fetch(url)).arrayBuffer();
const { instance } = await WebAssembly.instantiate(bytes, importObject);
cachedExports = instance.exports;
} catch (bufferErr) {
console.error("wasm unavailable at runtime, using JS:", bufferErr);
cachedExports = (await import("/dist/module.asm.js")).default;
}
}
return cachedExports;
}
The shape of importObject — the env.memory, function imports, and table — is identical across the Wasm and JS paths only if you design the JavaScript fallback to expose the same exported function names. Keep the two surfaces in lockstep or your callers will branch on which engine loaded, defeating the purpose of the fallback.
Tradeoffs: when fallbacks still earn their weight
Fallbacks are not free. Each one you ship is a second implementation to test, an extra artifact to download conditionally, and a code path that silently rots if you never exercise it. Weigh that against your real audience.
| Scenario | Worth a fallback? | Why |
|---|---|---|
| Base Wasm on browsers from 2017+ | No | >99% global support; the no-Wasm tail is mostly bots and ancient kiosks. A polite error beats a JS reimplementation. |
| SIMD / threads builds | Yes | Support is real but uneven across Safari versions and locked-down enterprise fleets; ship a non-SIMD .wasm as the floor. |
Hard-locked corporate browsers / CSP without wasm-unsafe-eval |
Yes | Wasm may be present but blocked; only a runtime try/catch to JS keeps the feature working. |
| Embedded webviews of unknown vintage | Sometimes | Depends on whether the feature is core to the page or progressive enhancement. |
The honest position for 2026: a base-Wasm JavaScript fallback is usually wasted effort, while a proposal-level fallback (SIMD build → baseline build) is genuinely load-bearing. Spend your budget probing proposals, not re-implementing arithmetic in JS for browsers nobody uses.
The wasm2js route also carries a hard cost: transpiled asm.js is typically 2–4× larger than the source .wasm and runs 2–10× slower, so treat it as a correctness floor, never a performance target. If the JS path is hot, hand-write a tuned implementation instead of shipping the transpiler output.
Gotchas & failure modes
CompileError: SIMD support is not enabled (or unexpected token 0xfd). The engine reached a SIMD opcode (0xfd prefix) it does not implement. This means you skipped the simd() probe and served the SIMD build to a non-SIMD engine. Probe first, or ship only the baseline build.
TypeError: Failed to execute 'compileStreaming': Incorrect response MIME type. Expected 'application/wasm'. The server returned the .wasm with Content-Type: application/octet-stream or text/html. instantiateStreaming is strict about the MIME type; the buffered instantiate(arrayBuffer) fallback in step 5 is precisely what rescues this case. Fix the server header for the fast path.
RuntimeError/LinkError from a threads() false positive. crossOriginIsolated is false, so SharedArrayBuffer is unavailable and a threaded module’s shared memory import fails. The threads() detector already accounts for this; if you rolled your own probe, check self.crossOriginIsolated explicitly before choosing the threaded artifact.
Silent fallback to a slower path. A try/catch that swallows the error degrades quietly — great for users, dangerous for you. Always console.warn (or beacon) on each downgrade so a region-wide MIME misconfiguration shows up in telemetry instead of as an unexplained latency regression.
Verification
Confirm both that detection reports the truth and that each branch loads. Validate the artifact itself first, then exercise the JS path by disabling Wasm.
# 1. Confirm the SIMD build really uses SIMD (so the probe is meaningful).
wasm-objdump -d module.simd.wasm | grep -i 'v128\|i32x4' | head
# 2. Confirm the baseline build does NOT (so non-SIMD engines accept it).
wasm-objdump -d module.baseline.wasm | grep -ci 'v128' # expect 0
# 3. Drive the JS fallback path under a Wasm-disabled Chromium.
npx playwright test --project=chromium-no-wasm
In the browser, the one-liner below tells you exactly which artifact your detection logic will pick on the current engine — run it in DevTools before shipping:
import("wasm-feature-detect").then(async (m) => {
console.table({
base: typeof WebAssembly === "object",
simd: await m.simd(),
threads: await m.threads(),
bulkMemory: await m.bulkMemory(),
crossOriginIsolated: self.crossOriginIsolated,
});
});
In this guide
- Setting up a local Wasm runtime for testing — run a
.wasmoutside the browser withnode,wasmtime, orwasmerto validate fallback artifacts before they reach a client.
Frequently Asked Questions
Do I still need a no-WebAssembly fallback in 2026?
Almost never for base Wasm — every evergreen browser has shipped it since 2017, so the unsupported tail is mostly automated clients. You do still need proposal-level fallbacks: serve a non-SIMD, non-threaded .wasm as the floor and upgrade only when WebAssembly.validate confirms the engine can run the optimized build.
What is the difference between WebAssembly.validate and a typeof check?
typeof WebAssembly === "object" tells you the API exists. WebAssembly.validate(bytes) tells you whether these specific bytes — including any proposal opcodes — would compile, returning a boolean synchronously without instantiating. Use typeof for base support and validate (via tiny probe modules) for per-feature detection.
Is wasm2js a real polyfill?
It is a transpiler, not a polyfill. wasm2js converts a .wasm into asm.js-style JavaScript ahead of time, so the output runs without any WebAssembly support at all. The cost is size and speed — expect 2–4× larger files and several-fold slower execution — so reserve it for the genuine no-Wasm tail.
Why does instantiateStreaming fail when instantiate succeeds?
instantiateStreaming requires the response to arrive with Content-Type: application/wasm; many static servers and CDNs send application/octet-stream instead. The buffered instantiate(arrayBuffer) path skips the MIME check, which is why the resilient loader falls back to it before giving up on Wasm entirely.
Can I detect threads without trying to instantiate a threaded module?
Yes. Check typeof SharedArrayBuffer === "function" && self.crossOriginIsolated together with a bulk-memory/atomics probe — the threads() helper in wasm-feature-detect bundles exactly that. A bare SharedArrayBuffer check is insufficient because cross-origin isolation can still be off.
Related
- Wasm instantiation lifecycle — the validate → compile → instantiate stages each fallback hooks into.
- Wasm binary format deep dive — the section and opcode layout the
validateprobe bytes encode. - Stack vs heap execution model — why fallback JS paths must not duplicate the
linear memoryfootprint. - Wasm optimization flags & size reduction — which proposals you compile in, and therefore which probes you must run.