Configuring COOP/COEP Headers for SharedArrayBuffer
This guide shows the exact response headers, per server, that make SharedArrayBuffer — and therefore
WebAssembly threads — available, and how to confirm they took effect.
SharedArrayBuffer exposes a high-resolution timing side channel that Spectre-class attacks exploit, so
browsers only define it when the document is cross-origin isolated. Isolation is an opt-in promise:
the page swears it shares no browsing context with cross-origin pages and embeds no subresource that
hasn’t consented. You make that promise with two response headers on the document —
Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp — and you keep
it by tagging every subresource with a Cross-Origin-Resource-Policy (or a valid CORS grant). Get all
three right and self.crossOriginIsolated flips to true, unlocking shared memory, Atomics, and
WebAssembly.Memory({ shared: true }).
The two headers play distinct roles. Cross-Origin-Opener-Policy: same-origin is about window
relationships: it severs the window.opener link to any page that isn’t same-origin, so a popup or
opener from another origin can no longer reach into your page’s globals. Cross-Origin-Embedder-Policy: require-corp is about subresources: it refuses to load any image, script, font, worker, or .wasm
that doesn’t explicitly state it may be embedded by your origin. Only when both hold does the browser
consider the page sealed enough to hand back the dangerous primitive. Miss either one and
crossOriginIsolated stays false, SharedArrayBuffer is undefined, and a threaded WebAssembly build
throws deep inside its loader rather than where the misconfiguration actually is. That indirection is why
the verification step at the end matters as much as the configuration itself.
Prerequisites
- [ ] A server whose response headers you control: Vite 5+, Express 4+, Caddy 2.7+, nginx 1.25+, or Cloudflare Pages/Workers
- [ ] A Wasm module built with threads (
-C target-feature=+atomics,+bulk-memory,+mutable-globalsfor Rust,-pthreadfor Emscripten) - [ ]
curlfor header inspection - [ ] A browser with DevTools (
self.crossOriginIsolatedis the ground truth) - [ ] Local dev over
http://localhostis fine; a real hostname requireshttps
Procedure
The configuration is the same shape on every server — set two headers on the document, tag subresources, verify — so the steps below are server-agnostic and the per-server snippets in step 1 are interchangeable. Work top to bottom the first time; once it works, the only step you revisit is verification.
-
Set both isolation headers on the document.
Cross-Origin-Opener-Policy: same-origindetaches the page from any opener;Cross-Origin-Embedder-Policy: require-corpmakes every subresource opt in. Apply the configuration for your server below.Vite — the dev and preview servers take a
headersmap:// vite.config.js import { defineConfig } from "vite"; export default defineConfig({ server: { headers: { "Cross-Origin-Opener-Policy": "same-origin", "Cross-Origin-Embedder-Policy": "require-corp", }, }, preview: { headers: { "Cross-Origin-Opener-Policy": "same-origin", "Cross-Origin-Embedder-Policy": "require-corp", }, }, });Express — set the headers in middleware ahead of the static handler, and pin
Content-Type: application/wasmso streaming compilation works:const express = require("express"); const app = express(); app.use((req, res, next) => { res.set("Cross-Origin-Opener-Policy", "same-origin"); res.set("Cross-Origin-Embedder-Policy", "require-corp"); next(); }); app.use( express.static("dist", { setHeaders(res, filePath) { if (filePath.endsWith(".wasm")) { res.set("Content-Type", "application/wasm"); res.set("Cross-Origin-Resource-Policy", "same-origin"); } }, }) ); app.listen(3000);Caddy — a
headerblock applies to all responses in the site:# 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" } }nginx — use
add_header … alwaysso the headers are emitted even on error responses:server { listen 8080; root /srv/dist; add_header Cross-Origin-Opener-Policy "same-origin" always; add_header Cross-Origin-Embedder-Policy "require-corp" always; location ~ \.wasm$ { types { application/wasm wasm; } add_header Cross-Origin-Resource-Policy "same-origin" always; } }Cloudflare Pages / Workers — a static
_headersfile at the project root sets headers per path:# _headers /* Cross-Origin-Opener-Policy: same-origin Cross-Origin-Embedder-Policy: require-corp /*.wasm Content-Type: application/wasm Cross-Origin-Resource-Policy: same-origin -
Make subresources satisfy
require-corp. Oncerequire-corpis on the document, every fetched asset must carryCross-Origin-Resource-Policy: same-origin(for same-origin assets) or a CORS grant pluscrossoriginattribute (for cross-origin ones). Same-origin worker scripts and.wasmfiles need the CORP header, shown in the.wasmrules above. -
Handle assets you cannot add CORP to. If a third-party resource refuses to send CORP or CORS headers, switch the embedder policy to
credentialless. It loads cross-origin subresources without credentials instead of requiring opt-in, and still grants isolation:Cross-Origin-Embedder-Policy: credentialless -
Construct shared memory the right way. Once isolated, create a single shared
WebAssembly.Memoryand hand it to every thread. Theshared: trueflag requires amaximum, because a shared memory’s backing buffer cannot be reallocated on growth — the engine reserves the maximum up front so all threads keep a stable view:const memory = new WebAssembly.Memory({ initial: 256, maximum: 4096, shared: true }); const worker = new Worker("/worker.js", { type: "module" }); worker.postMessage({ memory }); // structured clone shares the buffer, does not copy it -
Restart the server and hard-reload. Isolation is decided at document load. Restart so new headers apply, then reload with the cache disabled.
-
Verify in the console. Read
self.crossOriginIsolated; atruemeans the privilege is granted.
Expected output
From the command line, the document must carry both isolation headers and the .wasm must be CORP-tagged:
curl -sI http://localhost:8080/ | grep -iE 'cross-origin-(opener|embedder)-policy'
# cross-origin-opener-policy: same-origin
# cross-origin-embedder-policy: require-corp
curl -sI http://localhost:8080/app.wasm | grep -iE 'content-type|resource-policy'
# content-type: application/wasm
# cross-origin-resource-policy: same-origin
In the browser console, the runtime confirms isolation took effect:
self.crossOriginIsolated; // true
typeof SharedArrayBuffer; // "function"
new WebAssembly.Memory({ initial: 1, maximum: 4, shared: true }); // no throw
Gotchas
-
SharedArrayBuffer is not defined. The headers are missing, malformed, or were cached before they existed. Confirmself.crossOriginIsolated === truefirst — if it isfalse, no header on the.wasmfile alone will help, because the policy lives on the document. -
Third-party assets blocked by
require-corp. A fonts CDN, analytics tag, or cross-origin image with no CORP/CORS support fails to load and the console logsnet::ERR_BLOCKED_BY_RESPONSE.NotSameOriginAfterDefaultedToSameOriginByCoep. Switch that document toCross-Origin-Embedder-Policy: credentialless, or self-host the asset. -
Headers on the
.wasmbut not the HTML. COOP/COEP only do anything on the document response. The page that constructs the shared memory is the HTML, so that is where the policy must sit. -
A proxy or CDN strips the headers. Reverse proxies frequently drop
Cross-Origin-*headers. Re-runcurl -Iagainst the proxied port after adding any proxy in front of your origin. -
shared: truerejected with aTypeError. Constructingnew WebAssembly.Memory({ initial, shared: true })without amaximumthrows, because a shared memory must reserve its full address range up front — its buffer cannot be reallocated on growth while other threads hold views. Always supplymaximum. -
Worker fails to construct. With
require-corpactive, theWorkerconstructor fetches the worker script as a subresource. If/worker.jslacks the isolation and CORP headers, the constructor is blocked and the page never spawns its thread pool. Apply the headers site-wide.
Performance note
Cross-origin isolation has no measurable cost on its own — it is a policy flag, not extra work. The win
is downstream: once SharedArrayBuffer is available, a thread pool sharing one WebAssembly.Memory
sidesteps postMessage structured-clone copies entirely. Handing a 16 MiB buffer to four workers via
shared memory is a pointer hand-off measured in microseconds, where cloning it four times would move
64 MiB through the heap on every dispatch. The cost you do pay is up front and one-time: a shared
memory reserves its maximum address range immediately, so a generous maximum inflates the
instance’s reserved (not resident) footprint. Size it to the workload — large enough to avoid running
out mid-run, but not so large that you reserve gigabytes you never touch.
Frequently Asked Questions
What is the difference between require-corp and credentialless?
require-corp blocks any cross-origin subresource that does not explicitly opt in with a CORP or CORS
header — the strictest mode. credentialless instead loads cross-origin subresources without sending
credentials (cookies, client certs), so assets you don’t control still load while isolation is preserved.
Both yield crossOriginIsolated === true.
Do I need https for this to work?
On localhost, no — the browser treats it as a secure context, so SharedArrayBuffer and isolation work
over plain http. On any other hostname you need https; without TLS the browser withholds shared
memory regardless of your COOP/COEP headers.
Why is Cross-Origin-Resource-Policy needed on my own same-origin .wasm?
With require-corp active, the browser requires every subresource — even same-origin ones — to declare a
resource policy. Cross-Origin-Resource-Policy: same-origin is the explicit grant that lets your isolated
document load its own .wasm. The deeper threading model that this unlocks lives in
SharedArrayBuffer, Atomics & threading.
Related
- SharedArrayBuffer, Atomics & threading — the threading model these headers unlock.
- ESM bindings & module generation — packaging the threaded
.wasmand its glue. - Wasm instantiation lifecycle — how a shared memory is bound at instantiation.
← Back to Local Development Server Configurations