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-server npm package, Caddy 2.7+, or nginx 1.25+
  • [ ] python3 -m http.server available as a zero-dependency fallback (note: it already maps .wasm to application/wasm since Python 3.9)
  • [ ] curl for inspecting response headers from the command line
  • [ ] A modern browser whose DevTools console exposes self.crossOriginIsolated
  • [ ] A built .wasm artifact in a served directory (e.g. dist/ or pkg/)
  • [ ] For shared memory: a module compiled with threads enabled (-C target-feature=+atomics,+bulk-memory for Rust, -pthread for 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.

Dev server headers for streaming and isolation The browser requests the document and the wasm binary; the dev server must answer with COOP same-origin and COEP require-corp on the document, and Content-Type application/wasm plus a cross-origin resource policy on the binary, so streaming instantiation works and SharedArrayBuffer is enabled. Browser Dev server GET /index.html 200 · COOP: same-origin · COEP: require-corp document is crossOriginIsolated GET /app.wasm (fetch) 200 · Content-Type: application/wasm · CORP MIME ok → stream-compile instantiateStreaming() · new Memory({ shared: true })

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

  1. Build the module into a served directory. For Rust, wasm-pack build --target web emits a pkg/ folder; for Emscripten, emcc … -o dist/app.js emits the .wasm alongside its loader. Point your dev server at that directory.

  2. Confirm the MIME type. Before touching headers, check that your server already labels .wasm correctly. 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
  3. Add the isolation headers to the document response. Configure your server to send Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp on the HTML — configs for each server follow below.

  4. Make subresources opt in. Anything the isolated page loads (the .wasm, worker scripts, fonts, images) needs Cross-Origin-Resource-Policy: same-origin or a CORS grant, or require-corp blocks it.

  5. 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.

  6. Verify isolation in the console. Open DevTools and read self.crossOriginIsolated. A true value means SharedArrayBuffer, 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 (fetcharrayBuffer()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-stream or text/plain for .wasm makes instantiateStreaming reject with TypeError: Failed to execute 'compile' … Incorrect response MIME type. Expected 'application/wasm'. Fix the server’s MIME table or add an explicit Content-Type; do not work around it by switching to the slower ArrayBuffer path permanently.

  • Missing COOP/COEP disables SharedArrayBuffer with no error. When isolation headers are absent, self.crossOriginIsolated is false and SharedArrayBuffer is simply undefined in the global scope. A threaded module then throws ReferenceError: SharedArrayBuffer is not defined deep inside its loader, far from the real cause. Always confirm self.crossOriginIsolated === true first.

  • Headers on the .wasm but not the HTML. COOP/COEP must be on the document response, not the binary. Setting them only on .wasm responses 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 the curl -I check against the proxied port, not the origin port.

  • add_header clobbered by a nested block. In nginx, an add_header in an inner location block discards every add_header inherited from the parent. If a .wasm location adds CORP, it must also re-add any document-level headers it still needs, or use the always parameter and a flat structure.

  • A stale compiled module survives a rebuild. Because the engine caches compiled modules by URL, a rebuilt .wasm at the same path can still run the old code. The Cache-Control: no-cache headers 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-corp active, the Worker constructor fetches /worker.js as 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

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.

← Back to Compilation Pipelines & Toolchain Setup