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.
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.
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.
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-originon 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 keeprequire-corpif 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.
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.
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.notify → Atomics.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);
}
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.