Feature Detecting Wasm at Startup

This guide answers one task: determine whether WebAssembly is usable in the current page before your code depends on it — covering the cases where the object exists and compilation still fails.

Prerequisites

  • [ ] A page that loads a module.
  • [ ] A fallback path, even if it is only a clear message.
  • [ ] Somewhere to report the result, so you learn how often it matters.
  • [ ] Two minutes; this is a short check.

Why typeof WebAssembly is not enough

Every browser that matters has supported WebAssembly for years, so the naive check almost always passes — and the cases where a module still fails are exactly the ones worth detecting.

A Content Security Policy can block compilation. Historically script-src without unsafe-eval prevented it entirely; the modern wasm-unsafe-eval source expression permits WebAssembly without permitting eval, and a policy with neither blocks your module while typeof WebAssembly reports "object".

An extension or enterprise policy can disable it. Some security products block WebAssembly outright, and the object may remain present.

A memory failure can prevent instantiation on a constrained device even when compilation succeeds.

So the check that means something compiles and instantiates a tiny module, which exercises the whole path.

export function wasmUsable() {
  try {
    if (typeof WebAssembly !== 'object') return false;
    // (module (func (export "t") (result i32) (i32.const 42)))
    const bytes = Uint8Array.from([
      0x00,0x61,0x73,0x6d, 0x01,0x00,0x00,0x00,
      0x01,0x05,0x01,0x60,0x00,0x01,0x7f,
      0x03,0x02,0x01,0x00,
      0x07,0x05,0x01,0x01,0x74,0x00,0x00,
      0x0a,0x06,0x01,0x04,0x00,0x41,0x2a,0x0b,
    ]);
    const m = new WebAssembly.Module(bytes);
    const i = new WebAssembly.Instance(m);
    return i.exports.t() === 42;
  } catch {
    return false;
  }
}

Forty bytes, synchronous, under a millisecond, and it tests compilation, instantiation and a call rather than the presence of a global.

The object exists; the module still fails A content security policy can block compilation, a security product can disable WebAssembly, and a constrained device can fail to allocate. In each case the global object is present, so checking for it proves nothing. CSP blocks compilation no wasm-unsafe-eval CompileError at first use object still present policy or extension disabled by a security product rare, and real varies by environment allocation fails constrained device compiles, will not instantiate depends on your module's size A forty-byte probe catches the first two immediately; the third needs a real attempt with your real memory requirement.

Where the check belongs in the startup sequence

Running the probe is easy; running it at the right moment is what makes the result useful.

It belongs before any code path that assumes WebAssembly, and before the interface renders anything that depends on it. A button that appears and then turns out not to work is worse than one that never appeared, and worse again than one that appeared with an explanation.

Because the probe is synchronous, it can run during initial module evaluation without adding a round trip:

// capabilities.js — evaluated once, synchronously, at import time
export const CAPS = Object.freeze({
  wasm: wasmUsable(),
  streaming: typeof WebAssembly?.instantiateStreaming === 'function',
});
// anywhere that renders
import { CAPS } from './capabilities.js';

export function Toolbar() {
  return CAPS.wasm ? <LocalTools/> : <ServerTools notice="Processing happens on our servers" />;
}

Freezing the object makes it clear that this is a fact about the environment rather than state, and computing it at import time means every consumer sees the same answer without coordination.

The one thing not to do is scatter the check through the codebase. A single module that answers the question once, exports the answer, and is imported wherever the answer matters keeps the behaviour consistent and gives you one place to add the next capability.

Content security policy, specifically

This is the case most likely to affect a real deployment, because it is self-inflicted and easy to introduce.

# blocks WebAssembly compilation
Content-Security-Policy: default-src 'self'; script-src 'self'

# permits it, without permitting eval
Content-Security-Policy: default-src 'self'; script-src 'self' 'wasm-unsafe-eval'

The failure appears as a CompileError mentioning the policy in some browsers and as an opaque failure in others. If a module works locally and fails in production, and the environments differ in their security headers, this is the first thing to check.

curl -sI https://example.com/ | grep -i content-security-policy

Note that wasm-unsafe-eval is deliberately separate from unsafe-eval: adopting it lets you keep JavaScript eval blocked while allowing WebAssembly, which is a strictly better position than the historical workaround of allowing both.

Designing the fallback

Detection is only useful if something happens when it fails, and the something should be proportionate to what the module does.

Where the module is an optimisation — a faster path for work JavaScript can also do — the fallback is the JavaScript implementation, and the user notices nothing except speed.

Where the module is the feature, the fallback is a clear explanation and, where possible, a server-side alternative. “This tool needs WebAssembly, which your browser or security settings have disabled” is a message someone can act on; a spinner that never resolves is not.

const caps = { wasm: wasmUsable() };
report({ metric: 'startup-capabilities', ...caps });

if (!caps.wasm) {
  showNotice('Local processing is unavailable in this browser. Files will be processed on the server.');
  useServerPath();
} else {
  await loadModule();
}

Reporting the result is what tells you whether the fallback matters. For most consumer sites the failure rate is a fraction of a percent; for an enterprise deployment behind a strict policy it can be far higher, and the number decides how much the fallback deserves.

Detect, then choose a path The probe runs at startup. On success the module loads as usual. On failure the application takes a documented fallback path and reports the outcome, so the rate is known rather than guessed. 40-byte probe works fails load the module the normal path report: wasm ok fallback path server, JS, or a clear message report: wasm unavailable

Testing the failure path

The fallback is the part that decays, because nobody exercises it by accident. Two cheap habits keep it working.

Add a way to force it. A query parameter or a development flag that makes the probe return false regardless lets anyone check the fallback in a moment, and makes it reviewable in a pull request.

const forced = new URLSearchParams(location.search).get('nowasm') === '1';
export const CAPS = Object.freeze({ wasm: !forced && wasmUsable() });

Then put it in a browser test, so the path is asserted rather than assumed:

test('works without WebAssembly', async ({ page }) => {
  await page.goto('/app?nowasm=1');
  await expect(page.getByText('Processing happens on our servers')).toBeVisible();
  await page.getByRole('button', { name: 'Upload' }).click();
  await expect(page.getByRole('status')).toContainText('Uploaded');
});

That test takes a minute to write and it is what stops the fallback from becoming a code path that has not run in two years. A fallback nobody has exercised is not a fallback; it is a comment claiming there is one.

Route a small fraction of real traffic through it if the feature matters enough — the same argument as any other rarely used path, and the only way to find out whether it still works at scale.

Expected output

A healthy startup reports the capability and proceeds:

startup: { wasm: true }
module ready in 58 ms
# a page with a restrictive policy
startup: { wasm: false }
notice shown: local processing unavailable
Refused to compile WebAssembly module because 'wasm-unsafe-eval' is not an allowed source of script

That console message is browser-specific and is the clearest confirmation that the policy is the cause. Where it is absent, comparing the deployed headers against a working environment is the next step.

Choosing a build at startup A short probe decides which binary to fetch. The decision happens before any large download, so a device never pays for a build it cannot run. probe features compile tiny binaries pick a build best supported fetch that binary one download only JS fallback if none is usable Probing costs well under a millisecond, and it saves downloading a binary the engine would refuse. The fallback path must be real and tested — an untested fallback is the same as having none. Cache the result for the session; the answer cannot change while the page is open.

Gotchas

  • Checking typeof WebAssembly only. Passes in every case that actually fails.
  • Detecting asynchronously and rendering before the answer. The interface shows a feature that then does not work; either await the check or render the fallback state first.
  • No fallback at all. A blocked module becomes a page that does nothing, with no explanation.
  • Testing only in a permissive environment. Add a build of your own site with a restrictive policy to catch this in review.
  • Reporting nothing. The failure rate stays unknown and the fallback stays untested.
  • Assuming a failure is permanent. A policy can change between visits; do not persist the result.

Performance note

The probe compiles and instantiates in 0.2–0.6 ms on a laptop and about 1.5 ms on a phone, all of it at startup and once per page. That is small enough to run unconditionally before any decision that depends on WebAssembly, and it removes an entire category of failure that would otherwise surface as a stuck interface.

Frequently Asked Questions

Should I check for streaming compilation separately? Only if you rely on it. WebAssembly.instantiateStreaming may be absent in older environments, and the fallback to arrayBuffer() is two lines — most loaders include it unconditionally rather than detecting.

Does the probe itself need a fallback? No — it is wrapped in try/catch and returns false for anything that goes wrong, which is the correct answer for every failure mode it can encounter. That is why the whole check fits in one function with no branching on why it failed.

What about Node and workers? The same probe works in both. A worker inherits the page’s policy, so a module blocked on the page is blocked in its workers too.

Is it worth detecting individual proposals here? Separately, once basic availability is established — see detecting proposal support at runtime. Bundling everything into one check makes the failure less specific than it needs to be.

Two minutes of detection and a tested fallback turn a class of silent, unreproducible failure into a number on a dashboard that somebody can act on.

← Back to Polyfill Alternatives & Fallbacks