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.
  • [ ] curl for 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');
} });
What the right content type buys With the correct type, compilation overlaps the download and finishes shortly after the last byte. With the wrong type the fallback downloads the whole module first and compiles afterwards, adding the full compile time to the end. application/wasm — streaming download compile, overlapping — ready at 550 ms wrong type — fallback download then compile — ready at 790 ms The gap is the whole compile time, paid at the end instead of during the transfer — larger for bigger modules, which are the ones that can least afford it.

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.

Four headers, four jobs Content type enables streaming compilation, content encoding reduces transfer size, cache control removes repeat downloads, and the isolation headers enable threads. Each addresses a different cost. Content-Type application/wasm streaming compile Content-Encoding br, precompressed −70% transfer Cache-Control immutable + hash second visit free COOP + COEP on the document threads available Three of the four are set on the .wasm response; the isolation pair belongs on the HTML document, which is where people most often put them in the wrong place.

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.

Four headers, four consequences Each header changes something visible: whether streaming compilation is used, how long the file is cached, whether it is compressed, and whether threads are available. Content-Type wrong type disables streaming compilation entirely Cache-Control a hashed filename plus a long max-age, or repeat downloads Content-Encoding must match the precompressed body actually served COOP + COEP absent means no SharedArrayBuffer and no threads The first is the one people miss: a generic octet-stream type makes every load slower for no reason. Check them with a request against production, not against the dev server, which often differs.

Gotchas

  • Content-Type set 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-corp breaking third-party embeds. Use credentialless first.
  • 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.

← Back to Serverless & Edge Deployment