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.
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.
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.
Gotchas
- Checking
typeof WebAssemblyonly. 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.
Related
- Providing a JavaScript fallback path — what to do when the check fails.
- Detecting proposal support at runtime — the next layer of detection.
- Security implications of Wasm in enterprise apps — why a policy might block it.
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