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 SharedArrayBuffer and 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.

Deciding which build to load If crossOriginIsolated is true and SharedArrayBuffer exists, load the threaded build. If not isolated, load the single-threaded build and report which header is missing. If isolated but shared memory creation still fails, fall back and report the error. Can this page use shared memory? isolated + SAB present load the threaded build not isolated single-threaded build; report COOP/COEP status isolated, memory fails fallback; report the instantiate error

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.

Why a page is not cross-origin isolated The usual causes, checked in order: an insecure context, a missing COOP header, a missing COEP header, an embedding parent that is not isolated, and subresources blocked by COEP that break the page after isolation is enabled. secure context? HTTPS or localhost COOP header? same-origin COEP header? require-corp / credentialless top-level or isolated parent? iframes inherit subresources allowed? CORP / CORS on each

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. Check crossOriginIsolated.
  • 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 credentialless if needed.
  • OAuth popups failing. COOP: same-origin severs window.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.

Sessions on the threaded build during an isolation rollout Percentage of sessions loading the threaded build at each stage of a cross-origin isolation rollout, from before the change through report-only mode, enforcement on the app routes, and enforcement site-wide. % of sessions on the threaded build before rollout 0 % report-only headers 0 % enforced on app routes 71 % enforced site-wide 96 %

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.

← Back to SharedArrayBuffer, Atomics & Threading