Local Development Server Configurations
A .wasm file is not just another static asset. Serve it with the wrong Content-Type and
WebAssembly.instantiateStreaming refuses to compile it; forget two response headers and
SharedArrayBuffer disappears from the global scope, taking Wasm threads with it. The browser
applies stricter rules to WebAssembly than to JavaScript, and a generic file server that “just
works” for .js will quietly break streaming compilation and cross-origin isolation. This guide
shows exactly which headers a dev server must emit, why each one matters, and the precise
configuration for the four servers most full-stack teams actually run: Vite, the http-server
npm package, Caddy, and nginx.
The two problems to solve are independent but easy to conflate. First, MIME correctness: the
streaming API checks the response Content-Type and only accepts application/wasm. Second,
cross-origin isolation: shared memory is gated behind Cross-Origin-Opener-Policy and
Cross-Origin-Embedder-Policy headers on the document, not the .wasm file. Get the first
wrong and a single-threaded module fails to load fast. Get the second wrong and a multithreaded
build loads but runs single-threaded with no error you can grep for.
A third, quieter problem is cache coherence during development. A .wasm artifact is rebuilt
on every source change, but browsers and dev servers cache aggressively by Content-Type and
ETag. Serve a stale binary and you debug code you no longer have on disk. The right local posture
is the inverse of production: disable caching on the .wasm so every reload fetches the freshly
compiled bytes, and only re-enable long-lived caching once you ship a hashed, immutable filename.
Each configuration below pairs the correctness headers with a development-appropriate cache policy,
so the four servers behave identically and you can swap between them without surprises.
Prerequisites
- [ ] A dev server you control headers on: Vite 5+, the
http-servernpm package, Caddy 2.7+, or nginx 1.25+ - [ ]
python3 -m http.serveravailable as a zero-dependency fallback (note: it already maps.wasmtoapplication/wasmsince Python 3.9) - [ ]
curlfor inspecting response headers from the command line - [ ] A modern browser whose DevTools console exposes
self.crossOriginIsolated - [ ] A built
.wasmartifact in a served directory (e.g.dist/orpkg/) - [ ] For shared memory: a module compiled with threads enabled (
-C target-feature=+atomics,+bulk-memoryfor Rust,-pthreadfor Emscripten)
How the browser fetches and validates a module
When WebAssembly.instantiateStreaming(fetch("/app.wasm")) runs, the engine pipes the fetch
response body straight into the compiler as bytes arrive — it never buffers the whole file first.
Before it begins, it inspects the response Content-Type. If that header is anything other than
application/wasm the promise rejects with a TypeError, and you fall back to the slower
ArrayBuffer path. Separately, if your document was loaded cross-origin-isolated, every
subresource — including the .wasm — must satisfy the embedder policy or the fetch itself is
blocked. The diagram below traces both checks.
Two headers govern the document and two govern each .wasm response. On the document:
Cross-Origin-Opener-Policy: same-origin severs the relationship with any opener window, and
Cross-Origin-Embedder-Policy: require-corp forces every subresource to explicitly opt in. On
each served asset: Content-Type: application/wasm for streaming, and
Cross-Origin-Resource-Policy: same-origin (or a valid CORS grant) so an isolated document is
allowed to load it. The interplay of these headers and the broader threading model is detailed in
configuring COOP/COEP headers for SharedArrayBuffer.
Workflow: from build artifact to verified isolation
-
Build the module into a served directory. For Rust,
wasm-pack build --target webemits apkg/folder; for Emscripten,emcc … -o dist/app.jsemits the.wasmalongside its loader. Point your dev server at that directory. -
Confirm the MIME type. Before touching headers, check that your server already labels
.wasmcorrectly. Most modern servers do; the exceptions are old static servers with stale MIME tables.curl -sI http://localhost:5173/app.wasm | grep -i content-type # want: content-type: application/wasm -
Add the isolation headers to the document response. Configure your server to send
Cross-Origin-Opener-Policy: same-originandCross-Origin-Embedder-Policy: require-corpon the HTML — configs for each server follow below. -
Make subresources opt in. Anything the isolated page loads (the
.wasm, worker scripts, fonts, images) needsCross-Origin-Resource-Policy: same-originor a CORS grant, orrequire-corpblocks it. -
Restart and hard-reload. Browsers cache responses — and their headers — aggressively. Restart the server and do a cache-bypassing reload so the new headers actually apply.
-
Verify isolation in the console. Open DevTools and read
self.crossOriginIsolated. Atruevalue meansSharedArrayBuffer,WebAssembly.Memory({ shared: true }), and high-resolution timers are unlocked.
Server configurations
Each snippet below does two jobs: guarantee application/wasm on .wasm responses, and emit the
isolation headers. Pick the one matching your stack.
The four servers differ mainly in where the configuration lives and how MIME registration works.
Vite and Caddy ship a current MIME table and already know about WebAssembly, so you mostly add
headers; the http-server package and older nginx installs may carry a MIME table that predates
WebAssembly, so you register application/wasm explicitly. Vite is the right default for a
bundler-driven Rust or TypeScript app because the same config governs dev, preview, and HMR.
Reach for Caddy or nginx when you front the app with a real proxy and need to prove the headers
survive that hop; reach for http-server for a throwaway static check. Whatever you pick, the
verification step at the end is server-agnostic — curl and self.crossOriginIsolated tell you the
truth regardless of how the headers were produced.
Vite
Vite serves .wasm with the right MIME out of the box, so you only add the isolation headers via
the dev-server config. The same headers block applies to every response, which is what you want
for a fully isolated origin.
// vite.config.js
import { defineConfig } from "vite";
export default defineConfig({
server: {
headers: {
"Cross-Origin-Opener-Policy": "same-origin",
"Cross-Origin-Embedder-Policy": "require-corp",
},
},
// mirror the headers for `vite preview` builds:
preview: {
headers: {
"Cross-Origin-Opener-Policy": "same-origin",
"Cross-Origin-Embedder-Policy": "require-corp",
},
},
});
http-server (npm)
The http-server package needs the MIME mapping declared explicitly on older versions and accepts
arbitrary headers via repeated -H flags. The -c-1 disables caching so header edits take effect
immediately.
npx http-server ./dist -p 8080 -c-1 \
--mimetypes ./wasm.types \
-H "Cross-Origin-Opener-Policy: same-origin" \
-H "Cross-Origin-Embedder-Policy: require-corp"
# wasm.types — register the WebAssembly MIME type
application/wasm wasm
Caddy
Caddy’s config is the most compact. The header directive sets document headers globally; the
matcher narrows Content-Type to .wasm files. Caddy already knows the Wasm MIME, but pinning it
makes the behavior explicit and proxy-proof.
# Caddyfile
localhost:8080 {
root * ./dist
file_server
header {
Cross-Origin-Opener-Policy "same-origin"
Cross-Origin-Embedder-Policy "require-corp"
Cross-Origin-Resource-Policy "same-origin"
}
@wasm path *.wasm
header @wasm Content-Type "application/wasm"
}
nginx
nginx ships a mime.types file that may predate WebAssembly, so add the mapping in a types
block, then attach the isolation headers. add_header is inherited into nested blocks only if no
closer block redefines it, so keep the document and asset headers in the same server scope.
# nginx.conf (server block)
server {
listen 8080;
root /srv/dist;
types {
application/wasm wasm;
}
location / {
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Embedder-Policy "require-corp" always;
}
location ~ \.wasm$ {
add_header Cross-Origin-Resource-Policy "same-origin" always;
add_header Cache-Control "no-cache" always;
}
}
Loading the served module: the client-side binding
The server side is only half the contract; the application code that fetches and instantiates the
module has to match. A non-shared module needs nothing more than the streaming call. A threaded
module, however, must construct a shared WebAssembly.Memory, pass it into the module through
the import object, and hand the same memory to every worker — and that whole sequence only
succeeds when the document was served cross-origin isolated. The snippet below shows the full
binding for a threaded Rust module whose loader expects an imported memory.
// main.js — runs only if the document is cross-origin isolated
if (!self.crossOriginIsolated) {
throw new Error("not cross-origin isolated: serve with COOP + COEP headers");
}
// One shared linear memory for the main thread and every worker.
const memory = new WebAssembly.Memory({ initial: 256, maximum: 4096, shared: true });
const importObject = {
env: { memory }, // module imports memory rather than defining it
};
const { instance } = await WebAssembly.instantiateStreaming(
fetch("/pkg/app_bg.wasm"),
importObject
);
// Spin up a worker against the *same* memory — structured clone shares, never copies.
const worker = new Worker("/worker.js", { type: "module" });
worker.postMessage({ memory });
The worker re-instantiates the identical module bytes against the memory it received, so both
threads read and write one ArrayBuffer. That hand-off is exactly what cross-origin isolation
protects, and the full pattern — pthread pools, Atomics-based synchronization, and view
invalidation — is developed in the
SharedArrayBuffer, Atomics & threading
guides. The crucial server-side requirement is simply that both /index.html and /worker.js are
served with the isolation headers, or the Worker constructor itself is blocked by require-corp.
Streaming vs ArrayBuffer instantiation: the tradeoff
With the MIME type correct, instantiateStreaming compiles the module as bytes stream off the
network — compilation overlaps download, so time-to-instance is download time plus a sliver. The
ArrayBuffer path (fetch → arrayBuffer() → instantiate) waits for the full download, then
compiles, serializing two phases that streaming overlaps. On a 2 MB module over a throttled link
the difference is tens to low-hundreds of milliseconds. There is also a memory dimension: the
streaming path never materializes the whole binary as a JavaScript ArrayBuffer, so peak memory
during instantiation is lower — meaningful on memory-constrained devices loading multi-megabyte
modules. The cost of streaming is a stricter contract: it is the path that hard-fails on a wrong
MIME type, whereas the buffered path will happily compile bytes regardless of their Content-Type.
Keep a fallback anyway, because a mislabeled response or a CDN that strips the header will reject
streaming:
async function loadWasm(url, imports = {}) {
try {
const res = await fetch(url);
return (await WebAssembly.instantiateStreaming(res, imports)).instance;
} catch (err) {
// wrong MIME, or an engine that lacks streaming — buffer and retry
const res = await fetch(url);
const bytes = await res.arrayBuffer();
return (await WebAssembly.instantiate(bytes, imports)).instance;
}
}
The decision tree between these two paths, including how response caching interacts with the module cache, is covered in streaming instantiation vs ArrayBuffer instantiation, which sits within the broader Wasm instantiation lifecycle.
The second tradeoff is security versus convenience. Cross-origin isolation is a privilege: it
re-grants the high-resolution timers that Spectre mitigations took away, which is why the browser
demands require-corp. That same flag blocks any third-party asset that hasn’t opted in with its
own CORP or CORS headers — your isolated dev page may suddenly fail to load a fonts CDN or an
analytics script. Over plain http, localhost is treated as a secure context, so you can develop
isolated locally without TLS; the moment you deploy to a real hostname you need https for
SharedArrayBuffer to remain available.
The third tradeoff is freshness versus the module cache. The browser keeps a compiled-module cache
keyed on the response URL and validators, and an unchanged URL can serve a previously compiled
module even after you rebuild. During development the simplest defense is Cache-Control: no-cache
on the .wasm (present in every config above) plus a cache-busting query string when you swap a
module at runtime — fetch("/pkg/app_bg.wasm?t=" + Date.now()). In production you invert this:
emit a content-hashed filename like app_bg.4f3a91.wasm, mark it immutable, and let the cache
work for you, because the hash changes whenever the bytes do.
Gotchas and failure modes
-
Wrong MIME silently kills streaming. A server returning
application/octet-streamortext/plainfor.wasmmakesinstantiateStreamingreject withTypeError: Failed to execute 'compile' … Incorrect response MIME type. Expected 'application/wasm'.Fix the server’s MIME table or add an explicitContent-Type; do not work around it by switching to the slowerArrayBufferpath permanently. -
Missing COOP/COEP disables SharedArrayBuffer with no error. When isolation headers are absent,
self.crossOriginIsolatedisfalseandSharedArrayBufferis simply undefined in the global scope. A threaded module then throwsReferenceError: SharedArrayBuffer is not defineddeep inside its loader, far from the real cause. Always confirmself.crossOriginIsolated === truefirst. -
Headers on the
.wasmbut not the HTML. COOP/COEP must be on the document response, not the binary. Setting them only on.wasmresponses isolates nothing — the page that creates the shared memory is the HTML. -
A proxy strips the headers. Reverse proxies and some CDN tiers drop
Cross-Origin-*headers by default. After fronting your dev server with Caddy or nginx, re-run thecurl -Icheck against the proxied port, not the origin port. -
add_headerclobbered by a nested block. In nginx, anadd_headerin an innerlocationblock discards everyadd_headerinherited from the parent. If a.wasmlocation adds CORP, it must also re-add any document-level headers it still needs, or use thealwaysparameter and a flat structure. -
A stale compiled module survives a rebuild. Because the engine caches compiled modules by URL, a rebuilt
.wasmat the same path can still run the old code. TheCache-Control: no-cacheheaders above prevent this for fresh page loads; for runtime module swaps add a unique query string so the URL changes with the bytes. -
Worker scripts also need the headers. With
require-corpactive, theWorkerconstructor fetches/worker.jsas a subresource, so that script must carry the isolation and CORP headers too. Configure them site-wide, not just on.html, or the worker fails to spawn.
Verification
Confirm the MIME type and isolation headers from the command line with a single request, then confirm the runtime effect in the browser:
# the document must carry COOP + COEP
curl -sI http://localhost:8080/ | grep -iE 'cross-origin-(opener|embedder)-policy'
# cross-origin-opener-policy: same-origin
# cross-origin-embedder-policy: require-corp
# the .wasm must be application/wasm and CORP-tagged
curl -sI http://localhost:8080/app.wasm | grep -iE 'content-type|cross-origin-resource-policy'
# content-type: application/wasm
# cross-origin-resource-policy: same-origin
// DevTools console — the single source of truth for isolation
console.log(self.crossOriginIsolated); // expect: true
console.log(typeof SharedArrayBuffer); // expect: "function"
new WebAssembly.Memory({ initial: 1, shared: true }); // must not throw
If curl shows the right headers but self.crossOriginIsolated is still false, you are almost
certainly seeing a cached document — hard-reload with the cache disabled in the Network panel.
In this guide
- Configuring COOP/COEP headers for SharedArrayBuffer —
the exact header values for Vite, Express, Caddy, nginx, and a Cloudflare
_headersfile, with verification.
Frequently Asked Questions
Do I need any special headers for a single-threaded Wasm module?
No. A non-shared WebAssembly.Memory works with no COOP/COEP at all. You only need
Content-Type: application/wasm for streaming compilation. The isolation headers are exclusively
about unlocking SharedArrayBuffer and threads.
Why does my module load over http://localhost but break when deployed?
localhost is a secure context even over plain http, so SharedArrayBuffer and the streaming
API behave there. A deployed origin needs https; without TLS the browser withholds shared memory
and cross-origin isolation regardless of your headers.
Is python3 -m http.server enough for a quick test?
For single-threaded modules, yes — Python 3.9+ already serves .wasm as application/wasm, so
streaming works. It cannot set COOP/COEP, so it is unusable for anything that needs
SharedArrayBuffer; reach for Caddy or a Vite config there.
curl shows the right headers but crossOriginIsolated is false — why?
The document was served from cache before the headers existed. Disable cache in the Network panel
and hard-reload; isolation is decided at document load time and will not retroactively apply to a
cached page.
Why does my rebuilt module still run the old code?
The engine caches compiled modules keyed on the URL, so an unchanged path can serve the previously
compiled bytes. Serve the .wasm with Cache-Control: no-cache in development, and append a
cache-busting query string when you re-fetch a module at runtime so the URL changes whenever the
bytes do.
Can I avoid cross-origin isolation entirely?
Yes, if you never need shared memory. Single-threaded WebAssembly runs against an ordinary
non-shared memory with no COOP/COEP at all — you still want Content-Type: application/wasm for
streaming, but nothing more. Reserve the isolation headers for builds that genuinely use threads.
Related
- Configuring COOP/COEP headers for SharedArrayBuffer — header values per server, end to end.
- Wasm instantiation lifecycle — how the engine compiles and binds a module after the fetch.
- ESM bindings & module generation — packaging the
.wasmand its glue for import. - SharedArrayBuffer, Atomics & threading — what cross-origin isolation actually unlocks.
← Back to Compilation Pipelines & Toolchain Setup