Handling CompileError and LinkError
This page answers one task: loading a WebAssembly module can fail in several distinct ways — handle each one deliberately, show users something sensible, and report enough detail to fix the cause.
Prerequisites
- [ ] A page or app that loads at least one module.
- [ ] An error-reporting channel — console logs during development, an error tracker in production.
Three error types for three stages
Getting from bytes to a running module happens in stages, and the WebAssembly JavaScript API defines a distinct error type for failures in each. Knowing which stage failed is most of the diagnosis.
WebAssembly.CompileError means the bytes are not a valid module for this engine: the file is truncated or corrupted, it is not
WebAssembly at all (an HTML error page served with a 200 status is the classic case), or it uses a feature the engine does not support. It is
thrown by compile, compileStreaming, instantiate and instantiateStreaming, and also when a Content Security Policy blocks compilation.
WebAssembly.LinkError means the module is valid but cannot be connected to its host: an import is missing, has the wrong kind, or has an
incompatible type or size. It is thrown during instantiation, after compilation has succeeded.
WebAssembly.RuntimeError means a running module trapped: an out-of-bounds memory access, an unreachable instruction (often a Rust
panic), integer division by zero, a stack overflow inside Wasm, or an indirect-call signature mismatch. It can be thrown from instantiation —
if the start function traps — or from any later call into an export.
Ordinary TypeErrors and network errors also appear in loading code: a failed fetch, a wrong MIME type for streaming compilation, a
non-callable value where an import function was expected in some engines.
Step 1 — catch by stage, not with one generic handler
export async function loadModule(url, imports) {
let response;
try {
response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status} for ${url}`);
} catch (err) {
throw Object.assign(new Error("module download failed"), { stage: "fetch", cause: err });
}
try {
const { instance } = await WebAssembly.instantiateStreaming(response, imports);
return instance;
} catch (err) {
const stage =
err instanceof WebAssembly.CompileError ? "compile" :
err instanceof WebAssembly.LinkError ? "link" :
err instanceof WebAssembly.RuntimeError ? "start" : "other";
throw Object.assign(new Error(`module ${stage} failed`), { stage, cause: err });
}
}
Checking response.ok before compiling turns the most confusing failure — a 404 page compiled as WebAssembly, which surfaces as a
CompileError about the magic number — into a clear HTTP error. Wrapping errors with a stage field keeps the original error as the cause
for logs while giving callers something simple to branch on.
Step 2 — read the messages
Engines put specific, useful detail in each message. Recognising the common ones speeds up debugging:
CompileError: WebAssembly.instantiateStreaming(): expected magic word 00 61 73 6d, found 3c 21 44 4f @+0
3c 21 44 4f is <!DO — the start of an HTML document. The server returned a page instead of the module.
CompileError: WebAssembly.instantiateStreaming(): Compiling function #412 failed: invalid simd opcode @+18842
The module uses SIMD and the engine (or a flag, or a policy) does not support it — a feature mismatch, covered in detecting proposal support at runtime.
LinkError: WebAssembly.instantiate(): Import #7 "wbg" "__wbg_new_8a6f238a": function import requires a callable
The import object does not match the module — almost always glue from one build paired with a module from another.
Step 3 — respond appropriately to each
Each stage calls for a different response. Fetch failures are often transient: retry with backoff once or twice, then show an offline message. Compile failures are not transient: a corrupted or unsupported module will fail the same way every time, so fall back — to a different build, a JavaScript implementation, or a “not available” state — as in shipping a JavaScript fallback for a Wasm feature. Link failures are deployment bugs: the page and the module disagree. Retrying will not help, but a reload might, if a stale cached file caused it. Runtime failures during start are bugs in the module’s initialisation; report them with full detail.
try {
engine = await loadModule(url, imports);
} catch (err) {
report(err); // always send stage + cause to monitoring
if (err.stage === "fetch") return showOffline();
if (err.stage === "link") return promptReload("A new version is available. Reload to continue.");
return useFallback(); // compile / start failures
}
Step 4 — report enough to fix it
A report that says “CompileError” is not actionable; one that says “CompileError at @+0, found 3c 21 44 4f, URL /assets/app-3f9a1c.wasm,
build 2.4.0” is. Include the stage, the engine’s message, the module URL (with its hash), the page’s build identifier, and the browser. For
LinkError, include the failing import name. For RuntimeError, include the stack trace — readable if the module kept its name section, as
described in reading Wasm stack traces.
Error trackers group these well once the stage is a tag.
Step 5 — prevent the common ones
Most loading errors in production are configuration and deployment problems rather than bugs, and each has a cheap preventive check. Serve
modules with Content-Type: application/wasm and return real 404s instead of HTML fallbacks for missing assets. Use content-hashed file names so
glue and module always come from the same build — the approach in
versioning Wasm files with content hashes.
Include 'wasm-unsafe-eval' in your Content Security Policy. And run a headless-browser smoke test against the production build in CI that
simply loads the module — it catches every one of these before users do.
Why the API distinguishes the stages
It would have been simpler to throw one generic error type from every WebAssembly call. The specification separates them because the stages
genuinely mean different things, and code that handles them needs to know which happened. Compilation is a pure function of the bytes and the
engine, so its failures are deterministic and say something about the file. Linking depends on what the host provides, so its failures say
something about the page. Traps depend on inputs and state, so they say something about a particular run. A host that conflates them retries
when it should fall back, or falls back when a reload would have fixed things. The distinct types make the right response a simple
instanceof check.
Expected output
With an HTML page served at the module URL, the loader reports:
module compile failed { stage: "compile", cause: CompileError: … expected magic word 00 61 73 6d, found 3c 21 44 4f @+0 }
and the page shows its fallback instead of a blank area.
Gotchas
- Catching everything as one error. Retrying a
CompileErroror falling back on a transient network error both waste time. Branch on stage. - Single-page-app fallbacks serving HTML for
.wasmURLs. A catch-all route returnsindex.htmlfor missing modules. Exclude asset paths. - Retrying forever on fetch failures. Cap retries and back off, or a dead CDN becomes a busy loop on every client.
- Hiding the original error. Wrapping without
causeloses the engine’s message, which is the most useful part. - Treating
LinkErroras a module bug. It is almost always mismatched files. Check caching and deployment before the code.
Performance note
Error handling costs nothing on the success path. Checking response.ok before compiling saves real time on the failure path: a 2 MB HTML
error page that is fed to the compiler fails only after it has been downloaded, while the status check fails as soon as headers arrive — on a
slow connection, the difference was about a second.
Frequently Asked Questions
Can I tell CSP failures apart from other CompileErrors?
Their message mentions the Content Security Policy or unsafe-eval. Match on the message, and report it separately, because the fix is a header.
Is WebAssembly.validate a cheaper pre-check?
It validates without compiling to machine code, but it still requires the whole module in memory. It is useful for uploaded files, less so for
your own modules.
Can a module fail to compile in one browser and not another? Yes, when it uses a proposal one engine supports and another does not, or when it exceeds an engine-specific limit such as function size. Test in every engine you support.
Do these errors differ in Node?
The same types exist. Node adds its own errors for file-system loading, such as ENOENT before compilation even starts.
Should I catch RuntimeError from every export call? Catch it at the feature boundary — where a failed operation should become a message or a retry — not around every call.
Related
- Catching Wasm traps in JavaScript — the RuntimeError side in depth.
- Writing an import object by hand — avoiding LinkErrors.
- Serving Wasm with the correct MIME type locally — a frequent TypeError.
- Reporting Wasm crashes to an error tracker — getting these reports in production.
← Back to Wasm Instantiation Lifecycle