Detecting Cross-Origin Isolation at Runtime
This page answers one task: a site ships both a threaded and a single-threaded WebAssembly build, and at runtime it must reliably pick the threaded one only when it will actually work — and, when it will not, tell developers why.
Prerequisites
- [ ] A threaded build that needs
SharedArrayBufferand a fallback that does not. - [ ] Control over the server or CDN headers for the page and its resources.
Why isolation is required
SharedArrayBuffer, and therefore shared WebAssembly memory, can be used to build high-resolution timers, which make speculative-execution attacks such
as Spectre practical. Browsers re-enabled it only for pages that are cross-origin isolated: pages that promise not to share a process with
cross-origin windows and not to load cross-origin resources without their consent. A page becomes isolated by sending two headers on the top-level
document:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp (or: credentialless)
With both in place, self.crossOriginIsolated is true and SharedArrayBuffer is available. With either missing, the page works but shared memory does
not, and a threaded module fails to instantiate. Dedicated workers inherit isolation from their creator, but must themselves be served with a COEP
header in some browsers.
Step 1 — check the flag, then verify by creating shared memory
function sharedMemoryAvailable() {
if (!globalThis.crossOriginIsolated) return false;
try {
new WebAssembly.Memory({ initial: 1, maximum: 1, shared: true });
return true;
} catch {
return false;
}
}
crossOriginIsolated is the authoritative signal. The extra check creates a tiny shared memory, which catches environments where the flag is true but
shared WebAssembly memory is still unavailable — some embedded webviews and older engines. It costs microseconds.
Step 2 — load the matching build
const threaded = sharedMemoryAvailable();
const mod = threaded
? await import("./pkg-threads/engine.js")
: await import("./pkg/engine.js");
await mod.default();
if (threaded) await mod.initThreadPool(Math.max(1, Math.min(navigator.hardwareConcurrency - 1, 8)));
Dynamic import() ensures only one build is downloaded. Both builds should expose the same API so the rest of the application does not branch. If the
threaded build fails to initialise despite the check — for example a worker script blocked by COEP — catch the error and load the single-threaded build
as a second attempt, so users get a working page either way.
Step 3 — diagnose why isolation failed
When the threaded build is not used, developers need to know why. The page cannot read its own response headers directly, but it can fetch itself and inspect them, or check the conditions indirectly:
async function isolationReport() {
const res = await fetch(location.href, { method: "HEAD", cache: "no-store" });
return {
crossOriginIsolated,
coop: res.headers.get("cross-origin-opener-policy"),
coep: res.headers.get("cross-origin-embedder-policy"),
secureContext: isSecureContext,
inIframe: window !== window.top,
};
}
Common findings: one header present and the other missing (often COOP set on the HTML route but COEP only on assets); the page served over plain HTTP, where isolation is not available; a CDN or service worker stripping headers; or the page embedded in an iframe whose parent is not isolated, which prevents isolation regardless of the iframe’s own headers. Log the report in development and send it as telemetry in production — the share of sessions that fall back is worth tracking after deploys. Header configuration for common hosts is covered in configuring COOP and COEP headers for SharedArrayBuffer.
Step 4 — fix subresources that COEP blocks
Turning on require-corp blocks every cross-origin resource that does not opt in with Cross-Origin-Resource-Policy: cross-origin or CORS. Images from a
third-party CDN, analytics scripts, embedded videos and fonts are the usual casualties, and they fail quietly — a broken image, a missing font. Check the
console for ERR_BLOCKED_BY_RESPONSE.NotSameOriginAfterDefaultedToSameOriginByCoep messages. Fix each resource by adding CORP headers where you control
the server, loading it with crossorigin attributes where the provider supports CORS, or switching to Cross-Origin-Embedder-Policy: credentialless,
which allows no-CORS cross-origin requests without credentials and is often the pragmatic choice for pages with many third-party assets.
Step 5 — keep workers and popups in mind
Workers created from an isolated page are isolated if their scripts are served with compatible headers; serve worker scripts with the same COEP header to
be safe. COOP: same-origin severs the link between the page and cross-origin popups it opens or is opened by — OAuth and payment flows that rely on
window.opener break. same-origin-allow-popups keeps popups working but does not enable isolation; the usual answer is to perform such flows on a
separate, non-isolated page or with redirects instead of popups.
Rolling out isolation safely
Enabling isolation on an existing site can break things that were working, so roll it out with reporting first. Both COOP and COEP have
-Report-Only variants that log violations without enforcing them:
Cross-Origin-Opener-Policy-Report-Only: same-origin; report-to="coop"
Cross-Origin-Embedder-Policy-Report-Only: require-corp; report-to="coep"
Reporting-Endpoints: coop="https://example.com/reports", coep="https://example.com/reports"
Collect reports for a week: they list every resource COEP would block and every popup interaction COOP would break. Fix those, then switch to the enforcing headers — on the routes that need threads first, if the site is large. Throughout, the runtime detection above keeps every user on a working build: sessions that are not yet isolated simply load the single-threaded version. The share of threaded sessions in telemetry then measures the rollout’s progress directly.
Testing both paths in CI
Because the page silently falls back, a broken header configuration does not fail loudly — the site works, just more slowly. Guard against that with
automated checks. A deployment smoke test can request the production HTML and assert that both headers are present with the expected values, which takes
one HTTP request. An end-to-end test in headless Chromium can load the page and assert that crossOriginIsolated is true and that the threaded build
was chosen — expose the choice on a debug global such as window.__engineBuild. Run the same end-to-end suite a second time against a server
configured without the headers, asserting that the single-threaded build loads and the core features still work. Together these catch the two failure
modes that matter: losing isolation in production, which costs performance, and breaking the fallback, which costs users on hosts and browsers where
isolation is unavailable. The checks are cheap enough to run on every deploy.
Why the iframe case is special
Embedding is the case that most often surprises teams. A Wasm application delivered as an iframe inside another site — a widget, a document viewer, an
editor embedded in a CMS — can only be isolated if its top-level page is isolated too, and the embedding page must also delegate the permission with
allow="cross-origin-isolated" on the iframe element. Many embedding sites will never do that, so an embeddable product should treat the
single-threaded build as its primary path inside iframes and the threaded build as an optimisation for standalone use. The runtime check handles this
automatically; the product decision is to make sure the fallback is fast enough to be the default experience.
Expected output
On a correctly configured page, sharedMemoryAvailable() returns true and the threaded build loads with its worker pool. On a staging host missing COEP,
the single-threaded build loads, the page works, and the console shows { crossOriginIsolated: false, coop: "same-origin", coep: null, … }.
Gotchas
- Checking only
typeof SharedArrayBuffer. Some browsers expose the constructor without isolation, or not at all. CheckcrossOriginIsolated. - Headers on assets but not the HTML. Isolation is decided by the top-level document’s response.
- Service workers dropping headers. Responses constructed in a service worker must copy COOP and COEP.
- Third-party resources breaking silently. Use report-only mode first, then
credentiallessif needed. - OAuth popups failing.
COOP: same-originseverswindow.opener. Move those flows to a non-isolated page.
Performance note
The detection itself — reading the flag and creating a one-page shared memory — took under 0.05 ms in Chrome. Loading only the matching build saved the download of the other one: 410 KB for the threaded build versus 380 KB single-threaded, compressed.
Frequently Asked Questions
Does localhost count as a secure context?
Yes, so isolation can be tested locally over plain HTTP on localhost once the headers are set.
Can a page become isolated after loading? No. Isolation is fixed when the document is created. Changing headers requires a reload.
Is there an alternative to COOP/COEP? Chrome’s origin trials and enterprise policies offered temporary exceptions; there is no general alternative.
Do threads work in iframes?
Only if the top-level page is isolated and the iframe is allowed via allow="cross-origin-isolated".
Does credentialless weaken security?
It allows cross-origin no-CORS requests but strips credentials from them, so no user-specific data can be loaded from other origins. It is considered
safe for isolation.
Related
- Building Rust Wasm with threads using Rayon — the threaded build being chosen.
- Sharing memory between Wasm and Web Workers — what isolation unlocks.
- Serving Wasm over HTTPS on localhost — secure contexts in development.
- Writing a minimal Node dev server for Wasm — serving the headers locally.
← Back to SharedArrayBuffer, Atomics & Threading