Serving Wasm Files with the Right Headers
This guide answers one task: configure whatever serves your .wasm files so that streaming compilation
works, the transfer is as small as it can be, repeat visits cost nothing, and threading is available if
you need it.
Prerequisites
- [ ] Control over your server or CDN configuration.
- [ ] A build that emits hashed filenames, or the willingness to add one.
- [ ]
curlfor verification; the network panel for confirmation. - [ ] A test page that loads the module with
instantiateStreaming.
The content type is not optional
WebAssembly.instantiateStreaming compiles the module as bytes arrive, which overlaps download and
compilation and is the fastest path available. It requires the response to carry
Content-Type: application/wasm, and refuses anything else.
Content-Type: application/wasm
Serving application/octet-stream — the default for an unknown extension on many servers — produces this
in the console:
TypeError: Failed to execute 'compileStreaming' on 'WebAssembly':
Incorrect response MIME type. Expected 'application/wasm'.
Most code catches that and falls back to arrayBuffer(), which works and is slower, so the failure often
goes unnoticed until someone profiles the load. Fixing the type is usually one line:
# nginx
types { application/wasm wasm; }
# Apache
AddType application/wasm .wasm
// Express
express.static('public', { setHeaders: (res, p) => {
if (p.endsWith('.wasm')) res.setHeader('Content-Type', 'application/wasm');
} });
Compression: Brotli, and only once
WebAssembly binaries compress well — typically to 25–40% of their size with Brotli. Enable it, use a high quality level for static precompressed files, and do not compress twice.
# precompressed at build time, served directly
brotli_static on;
gzip_static on;
location ~ \.wasm$ {
add_header Content-Type application/wasm;
add_header Cache-Control "public, max-age=31536000, immutable";
}
# build step: produce .wasm.br alongside the binary
brotli -q 11 -o dist/engine.a91c3f.wasm.br dist/engine.a91c3f.wasm
ls -l dist/engine.a91c3f.wasm*
# 2148864 engine.a91c3f.wasm
# 612480 engine.a91c3f.wasm.br ← 28.5%
Compressing at request time with a high quality level is expensive; precompressing at build time gives the same result for free at serve time. Note that streaming compilation still works with a compressed transfer — the browser decompresses as it goes and feeds the decompressed bytes to the compiler.
What does not help is compressing an already-compressed payload, or applying Content-Encoding to a
response whose Content-Length you also need to be accurate for a progress bar, as discussed in
loading large model weights.
Cache immutably, with a hashed filename
A .wasm file should be cached for a year and never revalidated. That is only safe if the filename
changes when the contents do, which means hashing it at build time.
Cache-Control: public, max-age=31536000, immutable
<!-- the loader references the hashed name; both are emitted by the build -->
<script type="module">
import init from '/assets/engine.a91c3f.js';
await init('/assets/engine.a91c3f.wasm');
</script>
immutable tells the browser not to revalidate even on a reload, which removes a conditional request
that would otherwise cost a round trip. Without a hashed name, a long cache lifetime is a trap: users
will run last month’s module until the entry expires, and there is no way to invalidate it.
Serve the glue JavaScript with the same policy and the same hash discipline, because a mismatch between
glue and module is a LinkError at best and silent data corruption at worst.
Cross-origin isolation, if you need threads
A threaded build needs SharedArrayBuffer, which needs the document to be cross-origin isolated. Two
headers on the document response do it:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: credentialless
With isolation in force, cross-origin subresources must be loadable under the policy. credentialless
loads them without credentials, which breaks far less than require-corp, under which every cross-origin
resource must send Cross-Origin-Resource-Policy: cross-origin or be blocked outright.
If you serve your own .wasm from the same origin, it needs nothing extra. If it comes from a CDN on a
different origin, that CDN must send the resource policy header:
Cross-Origin-Resource-Policy: cross-origin
This is the single most common reason a threaded module works locally and fails in production, and the error message names neither the header nor the file. Self-hosting the binary avoids it entirely.
Security headers that apply to the module too
Delivery is not only about speed. Three more response headers affect what a .wasm can do and who can
fetch it, and they are easy to get wrong in a way nobody notices until an audit.
A Content Security Policy governs WebAssembly compilation. Historically unsafe-eval in script-src was
required to compile a module at all, which forced sites to weaken their policy for an unrelated reason.
The wasm-unsafe-eval source expression exists precisely to avoid that: it permits WebAssembly
compilation without permitting JavaScript eval.
Content-Security-Policy: default-src 'self'; script-src 'self' 'wasm-unsafe-eval'
X-Content-Type-Options: nosniff is worth setting globally and matters here specifically, because it
stops a browser or an intermediary from second-guessing the type you carefully configured. With sniffing
disabled, a misconfigured type fails loudly rather than working by accident on one browser and not
another.
And if the module is fetched cross-origin, Access-Control-Allow-Origin must permit your page, because
instantiateStreaming follows the ordinary fetch rules. A CDN that serves images happily to everyone may
not be configured for cross-origin script-like fetches, and the resulting failure mentions CORS rather
than WebAssembly.
curl -sI https://cdn.example.com/engine.wasm | grep -iE 'access-control|x-content-type|cross-origin'
# access-control-allow-origin: https://app.example.com
# x-content-type-options: nosniff
# cross-origin-resource-policy: cross-origin
Set all three deliberately. Each has produced a production incident somewhere whose diagnosis took longer than the fix, precisely because the error surfaced far from the header responsible.
Verify every one of them
Configuration you have not checked is configuration you do not have. One command per header.
curl -sI https://example.com/assets/engine.a91c3f.wasm
# HTTP/2 200
# content-type: application/wasm
# content-encoding: br
# cache-control: public, max-age=31536000, immutable
# content-length: 612480
# the document, for isolation
curl -sI https://example.com/ | grep -i cross-origin
# cross-origin-opener-policy: same-origin
# cross-origin-embedder-policy: credentialless
// in the page, the definitive check
console.log({ isolated: crossOriginIsolated, sab: typeof SharedArrayBuffer });
Add the curl checks to a post-deploy smoke test. Header configuration is exactly the kind of thing a CDN
change, a proxy addition or a framework upgrade silently alters, and the symptom — a slightly slower load,
or threads quietly disabled — is invisible without a check.
Gotchas
Content-Typeset on the origin but stripped by a CDN. Check the deployed URL, not the origin.- Compression applied twice. A precompressed file re-compressed at the edge wastes CPU and can break the encoding.
- Long cache lifetime without a hashed name. Users pinned to an old module with no way to update it.
- Isolation headers on the asset instead of the document. Isolation is a property of the document.
require-corpbreaking third-party embeds. Usecredentiallessfirst.- Range requests disabled. Rarely needed for a module, but required if you serve large data files the same way.
Performance note
For a 2.1 MB module on a 50 Mbit connection: with the correct type and Brotli, time to instantiated was
about 550 ms, of which compilation overlapped the transfer almost entirely. Without the correct type, the
same file took 790 ms because compilation started only after the last byte. Without compression it took
1.9 s. On a second visit with immutable caching, the whole sequence cost 8 ms. Four header lines
account for the difference between 1.9 s and 8 ms.
Frequently Asked Questions
Does Brotli interfere with streaming compilation? No. The browser decompresses as the stream arrives and feeds the decompressed bytes to the compiler, so both optimisations apply at once.
Should I use a service worker to cache the module?
It is an option, and Cache Storage gives you explicit control including offline support. For most sites
immutable HTTP caching with a hashed name is simpler and just as effective; reach for a service worker
when you need offline or a custom update strategy.
What about HTTP/3 and early hints?
Both help, in the ordinary way: a 103 Early Hints response with a preload link for the module lets the
browser start fetching before the HTML finishes parsing. Worth doing for a module on the critical path,
and pointless for one loaded lazily.
Do these headers matter for a module loaded inside a worker? All of them except the isolation pair, which belongs on the document that created the worker. The worker inherits the document’s isolation state, so a worker cannot be isolated on its own — another reason the headers live where they do.
Is a separate subdomain for assets still worth it? Rarely, and it actively hurts here: a different origin brings CORS and cross-origin resource policy into play for no benefit under HTTP/2 and HTTP/3, where connection reuse makes the old sharding argument obsolete. Serve the module from the same origin as the page unless something forces otherwise.
Related
- Preloading Wasm with link rel=preload — starting the fetch earlier still.
- Configuring COOP/COEP headers for SharedArrayBuffer — isolation in depth.
- Compressing Wasm with Brotli for delivery — the build-side of compression.
← Back to Serverless & Edge Deployment