Debugging SharedArrayBuffer Cross-Origin Errors

SharedArrayBuffer is missing, or postMessage refuses to send it, because the document is not cross-origin isolated — this page turns that one-line symptom into a specific header, proxy or subresource fix.

The workflow below extends the thread-attachment and Network-panel techniques in Chrome DevTools Worker Debugging, which in turn sits inside Debugging, Profiling & Production Optimization. For the memory model and locking primitives you are trying to reach on the other side of the fix, see SharedArrayBuffer & Atomics.

COOP / COEP required for SharedArrayBuffer

SharedArrayBuffer is unconditionally disabled unless the top-level document is served with both Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp, over a secure context. Without them typeof SharedArrayBuffer === 'undefined' in every agent — page, dedicated worker, shared worker — regardless of browser version. The one runtime source of truth is self.crossOriginIsolated: it must be true, and it must be true inside the worker as well as on the page.

Minimal Reproducible Example

Three files reproduce the failure and, with one header change, the fix. The server is deliberately plain so the headers are the only variable.

// server.ts — Node 20+, no dependencies
import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';

const ISOLATED = process.env.ISOLATED === '1';

createServer(async (req, res) => {
  const path = req.url === '/' ? 'index.html' : req.url!.slice(1);
  const body = await readFile(new URL(path, import.meta.url));

  // These two headers are the entire difference between working and broken.
  if (ISOLATED) {
    res.setHeader('Cross-Origin-Opener-Policy', 'same-origin');
    res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp');
  }
  res.setHeader('Content-Type', path.endsWith('.js') ? 'text/javascript' : 'text/html');
  res.end(body);
}).listen(8080);
<!-- index.html -->
<script type="module">
  console.log('page isolated:', self.crossOriginIsolated);      // false without the headers
  const worker = new Worker('./worker.js', { type: 'module' });
  worker.onmessage = (e) => console.log('worker says:', e.data);
</script>
// worker.js — the failing agent
self.postMessage({ isolated: self.crossOriginIsolated });

// Throws "ReferenceError: SharedArrayBuffer is not defined" when isolation is off.
const shared = new SharedArrayBuffer(1024);
const view = new Int32Array(shared);
Atomics.store(view, 0, 42);
self.postMessage({ ok: Atomics.load(view, 0) });

Run it with ISOLATED=0 and the worker dies on line 4 with a ReferenceError; the error event fires on the Worker object on the main thread, not in the worker’s own console. Run it with ISOLATED=1 and both logs report true.

One HTTP response decides whether shared memory exists Left column: the document is served with no Cross-Origin-Opener-Policy and no Cross-Origin-Embedder-Policy, so self.crossOriginIsolated is false and both the window agent and the worker agent throw a ReferenceError because SharedArrayBuffer is not defined. Right column: the same document served with Cross-Origin-Opener-Policy same-origin and Cross-Origin-Embedder-Policy require-corp reports self.crossOriginIsolated true, the constructor is exposed in both agents, and one SharedArrayBuffer allocation is mapped into the window and the worker. One HTTP response decides whether shared memory exists ISOLATED=0 — plain response HTTP/1.1 200 OK no Cross-Origin-Opener-Policy no Cross-Origin-Embedder-Policy self.crossOriginIsolated === false window agent SharedArrayBuffer is not defined ReferenceError worker agent inherits the same policy container ReferenceError no headers → no shared memory in any agent ISOLATED=1 — both headers set HTTP/1.1 200 OK Cross-Origin-Opener-Policy: same-origin Cross-Origin-Embedder-Policy: require-corp self.crossOriginIsolated === true window agent constructor exposed worker agent same agent cluster SharedArrayBuffer(1024) one allocation, mapped into both agents
The two columns differ by exactly two response headers. Everything below them — the runtime flag, the constructor, and whether one allocation can be seen from both agents — follows from that single difference.

What Each Error Message Actually Means

The failure surfaces through four distinct messages, and each points at a different mistake. Reading the exact wording saves an hour of guessing.

Error you see What actually happened
ReferenceError: SharedArrayBuffer is not defined The constructor was never exposed to this agent. Isolation is off — or the code runs in an agent that inherited a non-isolated owner (a blob worker created by a non-isolated document, for example).
DataCloneError: Failed to execute 'postMessage' … could not be cloned SharedArrayBuffer exists (you are on an isolated page) but you posted it to an agent in a different agent cluster — typically a cross-origin iframe, a window opened with noopener, or a MessagePort that crosses an origin boundary. Shared memory never crosses agent clusters.
TypeError: Atomics.wait cannot be called in this context Isolation is fine; you called the blocking Atomics.wait on the main thread, where blocking is forbidden. Use Atomics.waitAsync there, or move the wait into a worker — the pattern is covered in Coordinating Workers with Atomics.wait and notify.
RangeError / failed construction of WebAssembly.Memory({ shared: true }) Same root cause as the ReferenceError: shared WebAssembly memory is backed by a SharedArrayBuffer and needs the identical isolation state.

The DataCloneError case is the one most often misdiagnosed as a header problem. If self.crossOriginIsolated is true on the sender and you still cannot post the buffer, stop auditing headers — you are posting across an agent-cluster boundary, and no header will change that.

Four strings, four different mistakes A decision tree that branches on the exact console string. ReferenceError, SharedArrayBuffer is not defined, means isolation is off and the fix is COOP plus COEP on the document. DataCloneError on postMessage means the target lives in another agent cluster, which no header changes. TypeError, Atomics.wait cannot be called in this context, means the blocking wait ran on the main thread and belongs in a worker or in waitAsync. A failed WebAssembly.Memory with shared true has the same root cause as the ReferenceError and takes the identical header fix. Four strings, four different mistakes Read the exact error string the wording names the mistake ReferenceError SharedArrayBuffer is not defined Isolation is off no headers, or an owner that was not isolated (a blob worker) Fix the response COOP + COEP on the doc DataCloneError could not be cloned on postMessage Isolation is fine the target lives in another agent cluster (a cross-origin frame) Fix the topology no header changes this TypeError Atomics.wait cannot be called in this context Isolation is fine you blocked the main thread, where blocking is forbidden Move the wait waitAsync, or a worker RangeError WebAssembly.Memory { shared: true } fails Isolation is off shared wasm memory is backed by a SAB, so it needs the same state Fix the response identical header work
Only the first and last branches are header problems. Reading the string before touching the config is what keeps you out of a fruitless COOP/COEP audit when the real fault is an agent-cluster boundary or a blocking call on the wrong thread.

Step-by-Step Walkthrough: The Four-Checkpoint Audit

1. Read the runtime flag on both threads

self.crossOriginIsolated is cheaper and more reliable than reading headers by hand, because it reflects the browser’s final verdict after every subresource has been evaluated.

// isolation-probe.ts — import on the page, and post the same probe from the worker
export interface IsolationReport {
  agent: 'window' | 'worker';
  isolated: boolean;
  hasSAB: boolean;
  secureContext: boolean;
}

export function probeIsolation(): IsolationReport {
  return {
    agent: typeof window === 'undefined' ? 'worker' : 'window',
    isolated: self.crossOriginIsolated === true,
    hasSAB: typeof SharedArrayBuffer !== 'undefined',
    secureContext: self.isSecureContext, // false on plain http:// beyond localhost
  };
}

A secureContext: false result ends the investigation immediately: isolation requires HTTPS (or http://localhost), so a staging box served over plain HTTP on a LAN address can never be isolated no matter what headers it sends.

2. Verify the document response, not the config file

Open Network, filter by Doc, click the top-level document and read the Response Headers pane. You are checking for two exact values — same-origin and require-corp. Cross-Origin-Opener-Policy: same-origin-allow-popups does not qualify. Chrome also summarises the verdict under Application → Frames → top → Security & Isolation, which names which header is missing.

A CDN or reverse proxy that strips headers is invisible in your config, so confirm from inside the running page:

async function readDocumentHeaders(): Promise<void> {
  const res = await fetch(location.href, { method: 'HEAD', cache: 'no-store' });
  const coop = res.headers.get('cross-origin-opener-policy');
  const coep = res.headers.get('cross-origin-embedder-policy');

  console.table({
    coop,                               // expect "same-origin"
    coep,                               // expect "require-corp" (or "credentialless")
    crossOriginIsolated: self.crossOriginIsolated,
  });
}

3. Find the subresource that demoted the page

If both headers are present and isolation is still false, a subresource or an embedder is at fault. Filter the Network panel by the Blocked status: Chrome names COEP as the blocking reason on each request that failed the check. The fix per resource is one of:

  • Ask the remote origin for Cross-Origin-Resource-Policy: cross-origin on its response.
  • Add crossorigin="anonymous" to the tag and rely on CORS (Access-Control-Allow-Origin).
  • Self-host the asset so it becomes same-origin.
  • Switch the page to Cross-Origin-Embedder-Policy: credentialless, which loads cross-origin subresources without credentials and needs no cooperation from the remote — supported in Chrome and Firefox, but not Safari, so keep require-corp if Safari is in scope.

Roll this out with Cross-Origin-Embedder-Policy-Report-Only and a ReportingObserver first: you get the complete list of offending resources without taking the site down.

4. Fix the origin or edge configuration

# Nginx — "always" is required, or the headers vanish on 4xx/5xx responses
add_header Cross-Origin-Opener-Policy   "same-origin" always;
add_header Cross-Origin-Embedder-Policy "require-corp" always;

The same two headers belong on every HTML entry point, not just /. Single-page apps served through a catch-all rewrite are the common miss: the rewritten route returns the shell from a different location block, and — as the first gotcha below explains — Nginx silently drops the inherited headers there.

Four checkpoints, each ruling out one class of cause The audit runs left to right. Checkpoint one reads self.crossOriginIsolated on the page and in the worker from the Console and rules out an insecure context. Checkpoint two reads the top-level document response under Network filtered to Doc and rules out a stripping proxy. Checkpoint three filters the Network panel by blocked requests to name the cross-origin asset that refused to opt in, ruling out a third-party tag. Checkpoint four fixes the Nginx or CDN configuration and rules out a per-location header drop. Four checkpoints, each ruling out one class of cause Checkpoint 1 Runtime flag self.crossOriginIsolated on page and in worker Console Rules out an insecure context Checkpoint 2 Document headers same-origin and require-corp, exactly Network › Doc Rules out a stripping proxy Checkpoint 3 Blocked subresource which cross-origin asset refused to opt in Network › Blocked Rules out a third-party tag Checkpoint 4 Edge configuration add_header … always on every entry point Nginx / CDN config Rules out a per-location drop Stop at the first checkpoint that fails A false flag at checkpoint 1 makes 2 to 4 meaningless; correct headers at 2 leave the blocked subresource as the only suspect left.
The order is not arbitrary — each checkpoint is cheaper than the one after it and eliminates the causes the later ones would otherwise have to consider.

Gotchas & Edge Cases

Nginx add_header does not merge across blocks. Any add_header inside a location block discards all add_header directives inherited from server or http. Declare COOP and COEP in an included snippet and re-include it in every location that sets its own headers, or your API route quietly loses isolation while / keeps it.

Dev server and preview server are configured separately. In Vite, server.headers covers vite dev only; preview.headers is a distinct key. A page that works in development and throws ReferenceError in preview is almost always this, not a code change.

Blob and data URL workers inherit, they do not acquire. A worker created from URL.createObjectURL(new Blob([src])) inherits the creating document’s policy container. If the document is not isolated, the blob worker is not either — and no header on the blob (there are none) can change that. Cross-origin module worker scripts additionally require CORS on the script response.

COOP: same-origin severs window.opener. Isolation is not free: popups you open and windows that opened you lose their cross-window references, and cross-origin iframes are blocked unless they serve their own COOP/COEP or you grant allow="cross-origin-isolated". Audit OAuth popups, payment frames and embedded video before enabling this in production.

A detached fallback buffer reads as empty. When isolation is unavailable and you fall back to transferring an ArrayBuffer, the sending thread’s reference is neutered — byteLength becomes 0. Code that reads the buffer after postMessage sees zeros, not stale data. The ownership rules are spelled out in Transferable Objects & Zero-Copy.

Transfer moves ownership; shared memory does not Top row, the fallback path: the main thread holds an ArrayBuffer whose byteLength is 16777216, calls postMessage with the buffer in the transfer list, and its own reference is left detached at byteLength 0 while the worker holds the only live copy of the bytes. Bottom row, the isolated path: one SharedArrayBuffer sits between the two agents, is never listed as transferable, is never detached, and both the main thread and the worker keep a live Int32Array view over it. Fallback path — postMessage moves ownership main thread before: buf.byteLength = 16 777 216 after: buf.byteLength = 0 (detached) postMessage transfer: [buf] worker holds the only live copy same bytes, new owner — nothing was copied Isolated path — both agents map one buffer main thread new Int32Array(sab) writes stay visible here SharedArrayBuffer never listed as transferable worker new Int32Array(sab) sees writes immediately
The trap is the second row of the top card: after a transfer the sender still has a variable, and reading it yields zero bytes rather than the stale data most code assumes.

Performance Note: What the Fallback Actually Costs

The degraded path is not free, and quantifying it decides whether the header work is worth doing. Structured clone runs at roughly 1.2 ms per MB on a 2023-class laptop (V8, Chrome 124), and it blocks the posting thread for that whole time. A worker handing back a 16 MB analysis result therefore burns about 19 ms per hop — more than a frame at 60 fps — plus a transient doubling of peak memory while both copies exist.

The comparison points are stark: a transferable ArrayBuffer costs about 0.08 ms regardless of size, and an Atomics.notifyAtomics.wait round-trip over shared memory costs about 12 µs. So the rule of thumb is: below roughly 1 MB per second of cross-thread traffic, the clone-based fallback is invisible and the isolation headers are optional; above it, every megabyte you clone costs a millisecond of blocked thread. Measure your own numbers with the harness in Measuring Structured Clone Cost with performance.now(), and use the trade-off rubric in postMessage vs SharedArrayBuffer: When to Choose Each before committing to isolation.

Ship the detection and the fallback together, so a stripped header at the edge degrades performance instead of breaking the feature:

type SharedOrPlain = SharedArrayBuffer | ArrayBuffer;

export function allocateWorkerBuffer(sizeBytes: number): SharedOrPlain {
  // Both checks matter: the flag can be true while the constructor is absent
  // in older engines, and vice versa behind experimental flags.
  const canShare = self.crossOriginIsolated === true
    && typeof SharedArrayBuffer !== 'undefined';

  return canShare ? new SharedArrayBuffer(sizeBytes) : new ArrayBuffer(sizeBytes);
}

export function sendBuffer(worker: Worker, buffer: SharedOrPlain): void {
  // Shared memory is never listed as transferable; a plain ArrayBuffer must be.
  const transfer = buffer instanceof ArrayBuffer ? [buffer] : [];
  worker.postMessage({ type: 'INIT_BUFFER', buffer }, transfer);
}
What one cross-thread hop costs A horizontal bar chart on a logarithmic millisecond scale. An Atomics notify to wait round trip over shared memory costs about 0.012 milliseconds, twelve microseconds. Transferring an ArrayBuffer costs about 0.08 milliseconds regardless of size. Structured clone costs about 1.2 milliseconds per megabyte, so a 16 megabyte result costs about 19 milliseconds per hop — the only bar that crosses the 16.7 millisecond frame budget marked on the chart. What one cross-thread hop costs 16.7 ms frame budget Atomics notify → wait Transferable ArrayBuffer Structured clone · 1 MB Structured clone · 16 MB 0.012 ms — a 12 µs round trip 0.08 ms, whatever the size 1.2 ms per MB 19 ms per hop 0.01 0.1 1 10 100 milliseconds per hop, log scale Chrome 124 · V8 · 2023-class laptop
Each step down the list is roughly an order of magnitude. Only the 16 MB clone crosses a frame budget — which is exactly the traffic level at which the header work starts paying for itself.

Instrument the fallback in production the same way you instrument any other degradation: emit self.crossOriginIsolated as a dimension on your worker latency metric. A regression in that dimension tells you a proxy change stripped a header long before a user reports jank.

Frequently Asked Questions

Why does SharedArrayBuffer still throw a ReferenceError even though I set COOP and COEP?
Either the headers never reached the browser, or one subresource demoted the document. Check self.crossOriginIsolated first — it is the canonical runtime answer, and it is available on both the page and inside the worker. If it is false, open DevTools → Application → Frames → top and read the Security & Isolation rows: they tell you which of the two headers is missing. If both headers are listed as present but isolation is still off, the page is being framed by, or opened from, a document that is not itself isolated, or a CDN/proxy is rewriting the response — re-check with fetch(location.href, { method: 'HEAD', cache: 'no-store' }) and read the headers off the real response.
Which third-party scripts break Cross-Origin Embedder Policy, and how do I find them?
Under Cross-Origin-Embedder-Policy: require-corp, every cross-origin subresource must opt in — either with a Cross-Origin-Resource-Policy: cross-origin response header, or through CORS with crossorigin="anonymous" on the tag plus a matching Access-Control-Allow-Origin. Anything that does neither is blocked. To find them, filter the Network panel by the Blocked status and open the Headers pane: Chrome names COEP as the blocking reason. Analytics beacons, ad tags, web fonts and CDN-hosted libraries are the usual offenders. Rolling out Cross-Origin-Embedder-Policy-Report-Only first gives you the same list without breaking production.

See also