WorkersSPEC-EVOLVINGBROWSER-SPECIFICGENERAL

Shared Memory and Cross-Origin Isolation

SharedArrayBuffer gives two threads one block of memory — and requires COOP and COEP headers that most pages cannot adopt without breaking their third-party embeds.

The intent, the obvious build, and why it breaks

Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.

The question

Why does the one primitive that avoids copying entirely require me to change my response headers, and what does turning it on break?

The user intent

A person scrubs the timeline of an in-browser video editor. Frames must appear as fast as the pointer moves, with a decode worker and the renderer looking at the same pixel buffer rather than passing it back and forth.

The obvious build

Allocate a SharedArrayBuffer, hand it to the worker, and let both sides read and write it. No copying, no transferring, no message overhead.

Why it breaks

new SharedArrayBuffer(n) throws on most pages. The constructor is gated behind crossOriginIsolated === true, which is false unless the document was served with specific headers.

How it breaks in a real browser
  • new SharedArrayBuffer(n) throws on most pages. The constructor is gated behind crossOriginIsolated === true, which is false unless the document was served with specific headers.
  • Adding those headers is not a one-line change. Cross-Origin-Embedder-Policy: require-corp makes the browser refuse every cross-origin subresource that does not explicitly opt in — images, fonts, scripts, iframes, all of it.
  • Cross-Origin-Opener-Policy: same-origin severs window.opener. OAuth popups, payment flows and social logins that post a message back to their opener stop working (Login Redirects and the Open-Redirect Trap).
  • Your analytics vendor, ad tag, embedded video, map widget and support chat almost certainly do not send Cross-Origin-Resource-Policy or CORS headers. They break, and you do not control the fix.
  • Even with the headers correct, you now have genuine shared mutable state in JavaScript — the one place the language has real data races, with tearing and reordering, and no Object.freeze to hide behind (Data Race Is Not Race Condition).
  • Atomics.wait() throws on the main thread by design. The synchronisation primitive that makes shared memory usable is unavailable on the thread that most needs not to block.
IntentEventStateUI LogicDOM WorkNetworkLayout / PaintPixelsFeedback

What is actually happening

In the browser, not in the framework.

  • A SharedArrayBuffer is neither cloned nor transferred. Post it and both realms address the same physical memory; a write from either side is visible to the other, subject to the memory model rather than to any message ordering (What a Memory Model Defines in Concurrency).
  • Atomics supplies the synchronisation: Atomics.load / store for tear-free access, add / compareExchange for read-modify-write, and wait / notify for blocking and waking a thread (Atomics: What Is Actually Indivisible).
  • Atomics.wait() blocks the calling thread. It is forbidden on the main thread — it throws — precisely because a blocked main thread is a frozen page and a frozen accessibility tree.
  • The gate is self.crossOriginIsolated, a boolean the browser sets when the document was served with Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp (or credentialless).
  • The reason is Spectre-class transient-execution attacks. They read memory speculatively and recover it through a timing side channel, and recovering anything requires a high-resolution clock.
  • Browsers responded by coarsening performance.now() and adding jitter. But a SharedArrayBuffer plus a worker incrementing a counter in a loop is a high-resolution clock — one the browser cannot coarsen, because it is just arithmetic on memory you own.
  • So the primitive was regated on process isolation instead: the headers guarantee the document is in a process containing nothing cross-origin worth reading. The timer is still precise; there is simply nothing next to it in memory to steal (The Multi-Process Browser).
  • COEP: credentialless is the softer variant: cross-origin subresources load without credentials instead of being blocked, which rescues public assets but not anything requiring a session cookie.

What this makes the browser do

And which of it is avoidable.

  • Checking every cross-origin subresource against Cross-Origin-Resource-Policy or a CORS response, and blocking those that fail — a check that runs for the whole page, not only for the parts using shared memory.
  • Guaranteeing that the document lands in a process with no cross-origin content in it, which increases process count and therefore memory footprint (Process Isolation: One Kernel, Many PID 1s in Operating Systems).
  • Enforcing the memory model on atomic operations, which on some architectures means real memory barriers with real cost (Memory Barriers: Ordering, Not Flushing in Concurrency).
  • What it no longer has to do: copy anything. That is the entire benefit, and for a per-frame pixel buffer it is a large one (Structured Clone and Transferables).

Why a memory primitive needs HTTP headers

The chain is worth following once, because without it the requirement looks arbitrary and gets treated as a configuration chore rather than a security property. Speculative execution leaves traces in the cache; reading those traces requires distinguishing timings that differ by a very small amount; browsers removed that ability by making clocks coarse and jittery.

A shared buffer defeats that mitigation completely. One worker in a tight loop incrementing a counter in shared memory gives the main thread an arbitrarily precise clock made of ordinary arithmetic — nothing the browser can coarsen. So the mitigation moved: rather than removing the clock, the browser guarantees that nothing worth reading is in the same process as the clock.

From side channel to response header
browsers responddefeated bythe priceSpeculative execution leaves cache tracesRecovering them needs a precise timerMitigation: coarsen performance.now()SAB + counting worker = precise timer againSo: gate SAB on process isolationCOOP: same-origin (sever opener)COEP: require-corp (embeds must opt in)crossOriginIsolated === trueCost: third-party embeds blocked, popups severednew SharedArrayBuffer(n) works
UserLLMAgentToolDataDecisionHumanGuardrail

What turning it on actually breaks

Enabling isolation is a change to how your page loads *everything*, not a change to one feature. The rows below are the ones teams actually hit, and most of them are outside your repository — which is what makes report-only mode non-negotiable as a first step.

Read the last row carefully. The most expensive outcome is not an error; it is a page that works perfectly while quietly no longer loading something that was earning money.

The blast radius of COOP/COEP
TriggerSymptomCauseResponse
COEP: require-corp deployedThird-party images, fonts and scripts fail to loadEvery cross-origin resource now needs Cross-Origin-Resource-Policy or a CORS response, and most vendors send neitherRun Cross-Origin-Embedder-Policy-Report-Only with a reporting endpoint first and enumerate every blocked URL.
COOP: same-origin deployedOAuth and payment popups complete but never inform the openerThe opener relationship is severed, so the popup's postMessage to window.opener has nowhere to goMove to a redirect-based flow, or keep those routes outside the isolated origin (Login Redirects and the Open-Redirect Trap).
CDN or proxy normalises headerscrossOriginIsolated is false in production, true locallyThe headers left the origin server but did not reach the browserAssert on the boolean at runtime and report it; check the response headers the browser received, not the ones you configured.
Page runs inside a customer's iframeIsolation never activates regardless of your headersIsolation is a property of the top-level document, which you do not controlShip the non-isolated fallback path and mean it; embedded contexts are permanent, not transitional.
Shared buffer written without atomicsOccasional visibly corrupted frame, unreproducible locallyA torn or reordered read of memory being written concurrentlyAtomic index publication with a memory model you can state, then stress-test it (Data Race Is Not Race Condition).
Atomics.wait() on the main threadTypeError, or on a worker a thread pinned foreverBlocking waits are forbidden on the main thread; on a worker a missed notify never wakesAtomics.waitAsync where available; re-check the condition in a loop rather than trusting a single wake.
Isolation enabled site-wide "to be safe"No errors; analytics, ads or support chat silently goneBlocked subresources fail in third-party code that swallows its own errorsIsolate only the route that needs it, and monitor third-party health as a release signal (Release Health).

Using it without lying to yourself

SPEC-EVOLVINGSpecific to the current gating rules: Atomics.waitAsync and COEP: credentialless are recent additions with uneven support, and the set of accepted header values has changed since the primitive returned. Verify the requirement against current documentation before relying on this exact shape.

If you do get isolated, the code is short and the discipline is not. The example is a single-producer, single-consumer ring buffer: atomic reads and writes on the two indices, plain access to the slots between them, and no blocking wait anywhere near the main thread.

Two things in it are load-bearing. Atomics.store on the write index happens *after* the slot is filled, which is what makes the data safe to publish. And the main thread polls rather than waits, because Atomics.wait there is not a performance mistake — it is a TypeError, and it would be a frozen accessibility tree if it were not.

Feature detection, a fallback, and a single-producer ring buffer
1// 1. The gate is a runtime boolean, not a build-time assumption.
2export const canShare =
3 typeof SharedArrayBuffer !== 'undefined' && self.crossOriginIsolated === true
4
5export function makePipe(slots: number, slotBytes: number) {
6 if (!canShare) return transferablePipe(slots, slotBytes) // exercised path, not a stub
7
8 // [0] = writeIndex, [1] = readIndex, then the slot data
9 const control = new SharedArrayBuffer(2 * Int32Array.BYTES_PER_ELEMENT)
10 const data = new SharedArrayBuffer(slots * slotBytes)
11 const idx = new Int32Array(control)
12 const bytes = new Uint8Array(data)
13 return { control, data, idx, bytes, slots, slotBytes }
14}
15
16// --- in the worker (producer) ---
17function publish(p: ReturnType<typeof makePipe> & { idx: Int32Array }, frame: Uint8Array) {
18 const w = Atomics.load(p.idx, 0)
19 const r = Atomics.load(p.idx, 1)
20 if ((w + 1) % p.slots === r) return false // full: drop, do not overwrite
21
22 p.bytes.set(frame, w * p.slotBytes) // 1. fill the slot (plain writes)
23 Atomics.store(p.idx, 0, (w + 1) % p.slots) // 2. THEN publish the index
24 Atomics.notify(p.idx, 0) // 3. wake anyone waiting
25 return true
26}
27
28// --- on the main thread (consumer) ---
29// Atomics.wait() THROWS here, by design: a blocked main thread is a frozen
30// page and a frozen accessibility tree. Poll from rAF instead.
31function drain(p: ReturnType<typeof makePipe> & { idx: Int32Array }, draw: (b: Uint8Array) => void) {
32 const tick = () => {
33 const w = Atomics.load(p.idx, 0)
34 let r = Atomics.load(p.idx, 1)
35 while (r !== w) {
36 draw(p.bytes.subarray(r * p.slotBytes, (r + 1) * p.slotBytes))
37 r = (r + 1) % p.slots
38 Atomics.store(p.idx, 1, r)
39 }
40 requestAnimationFrame(tick)
41 }
42 requestAnimationFrame(tick)
43}

The ordering in publish is the whole correctness argument: fill, then atomically store the index. Reverse those two lines and the consumer can read a half-written frame — with no error, on some devices, sometimes.

How to build it

Most important first.

  • Establish that you need it. If your payload crosses the boundary once per interaction, transferables are simpler and adequate. Shared memory earns its cost when the same buffer is touched many times per second from both sides.
  • Audit every cross-origin resource on the page before enabling COEP. The Cross-Origin-Embedder-Policy-Report-Only header tells you what would break without breaking it, and it is the only safe way to start (Frontend Error Tracking).
  • Check crossOriginIsolated at runtime and ship a working non-isolated fallback — transferables, or a slower single-threaded path. A page that only functions when the headers are right will eventually meet a deployment where they are not (Feature Flags in the Client).
  • Consider isolating only the route that needs it. A dedicated origin or path that serves the editor with COOP/COEP, while the marketing pages and the checkout keep their embeds, is usually the practical answer.
  • Keep atomics at the boundary and plain reads inside. A ring buffer with atomic head and tail indices, and ordinary reads and writes to the slots between them, is the pattern — not atomics everywhere (Concurrent Queues in Concurrency).
  • Use Atomics.waitAsync where available to wait on the main thread without blocking it, and fall back to postMessage as the wake-up signal where it is not.
  • Read the Concurrency module before writing any of it. Every classic hazard — torn reads, lost updates, reordering, ABA — is now genuinely available to you in JavaScript (Data Race Is Not Race Condition, Happens-Before: The Edge That Makes a Write Visible).

Keyboard, focus, semantics, announcement

A required field on every lesson in this domain, not a section added when there is room.

  • The strongest accessibility argument for shared memory is negative: it removes per-frame copies from the main thread, and the main thread is where the accessibility tree is computed and where announcements are delivered (The Accessibility Tree).
  • The strongest argument against a careless implementation is Atomics.wait(). Blocking the main thread does not merely drop frames — it freezes focus movement, stalls every live-region update, and leaves a screen-reader user with no signal at all. This is exactly why the specification forbids it there.
  • A worker with shared memory still cannot announce anything. Status has to arrive on the main thread — via postMessage, or via a flag the main thread polls in a requestAnimationFrame — and be written into a live region that already exists (Live Regions and Announcement).
  • Polling shared state from requestAnimationFrame to drive announcements must be throttled to human speed. A live region updated once per frame is worse than no live region.
  • COOP has a direct accessibility consequence people miss: severing window.opener breaks popup-based flows that assistive-technology users navigate the same way as everyone else, and a popup that cannot report back leaves them stranded with no error (Focus Management).

What can go wrong

Failure modes
  • SharedArrayBuffer is not defined in production only, because a CDN or proxy strips the COOP/COEP headers from responses your origin server sent correctly.
  • Enabling COEP and discovering at deploy time that the payment iframe, the session-replay script and the embedded video all stopped loading, with a console full of errors from third parties.
  • A silent data race producing plausible-looking wrong output. Nothing throws; a frame is simply rendered from half-written memory, intermittently, on some devices (Heisenbugs: The Bug That Leaves When You Look at It in Concurrency).
  • Torn reads on a value larger than the platform's atomic width when accessed without Atomics.load.
  • Atomics.wait() called on the main thread, throwing TypeError — or, worse, called on a worker that then never gets notified, silently pinning a thread forever.
  • The mitigation failing: a fallback path that is never exercised because staging is always isolated, so its first real run is in production (Choosing the Test Level).
  • The isolation itself being the regression: the page works, shared memory works, and conversion drops because an embed nobody owned stopped rendering.
What can arrive out of order
  • This lesson is the only place in the domain where genuine data races exist. Two threads writing overlapping regions without atomics produce torn, reordered or lost values, and nothing in the language will warn you (Data Race Is Not Race Condition).
  • A reader observing a buffer mid-write sees a frame composed of old and new pixels. It looks like a rendering bug and it is a synchronisation bug.
  • Index updates race the data they describe: a producer that publishes a write index before finishing the slot lets a consumer read a half-written slot. The fix is an atomic store with release ordering, which is Happens-Before: The Edge That Makes a Write Visible applied (Safe Publication: Handing Over a Finished Object in Concurrency).
  • Atomics.notify can be called before the corresponding Atomics.wait, and the wake is then simply lost. Waiters must re-check the condition in a loop rather than trusting the notification (Lost Wakeups: The Notify That Arrived Before the Wait in Concurrency).
Security
  • This is the rare frontend feature whose availability is a security decision rather than a capability decision. The headers do not protect *your* data — they attest that there is no cross-origin data in your process worth attacking.
  • COOP severs the opener relationship so a cross-origin document cannot keep a handle on your window; COEP ensures nothing cross-origin is embedded that could end up in the same address space.
  • CORP (Cross-Origin-Resource-Policy) is the header a *resource* sends to say who may embed it. Under COEP, a resource without it is blocked — which is why enabling COEP is a request you are implicitly making of every vendor you load.
  • None of this stops your own bugs. Shared memory across your own workers is fully within your origin's trust boundary; a race in your ring buffer is a correctness problem, not a security one (The Browser Security Model).
  • Do not treat isolation as a general hardening measure to enable everywhere. It is a targeted enabling mechanism with a large blast radius, and its cost is paid in broken third-party integrations (Third-Party Scripts and the Supply Chain).
Misreads
  • "SharedArrayBuffer is disabled for security, so using it is unsafe." It is gated so that *its precision cannot be pointed at other origins' data*. Within an isolated page it is a normal, supported primitive.
  • "COOP and COEP protect my users' data." They constrain what shares your process. Your data is protected by the same-origin policy, CSP and your server — the isolation headers are an enabling condition for a feature (The Same-Origin Policy).
  • "Shared memory is just a faster postMessage." Message passing has ordering and delivery guarantees. Shared memory has a memory model, and the difference is where every hard bug lives.
  • "I will add the headers and see what breaks." What breaks is third-party content in production, often silently and often revenue-bearing. Report-only mode exists for this reason.
  • "Atomics make it safe." Atomics make individual operations indivisible. They do not make a sequence of operations correct, and the sequence is where the bug is (Atomics Are Not Magic in Concurrency).
  • "If it works in my browser it works." The gate depends on headers surviving every proxy and CDN between your server and the user, and on a browser version that implements the current form of the requirement.

Measuring it, and what changes in the field

How you would see this
  • Log self.crossOriginIsolated from real sessions. It is one boolean and it tells you whether the feature exists for that user; a CDN change can flip it without any code deploying (Real User Monitoring).
  • Deploy Cross-Origin-Embedder-Policy-Report-Only with a reporting endpoint first. The reports enumerate exactly which resources would be blocked, by URL.
  • The Network panel marks blocked-by-COEP requests with a specific initiator failure; the Security panel and the response-header view confirm what actually arrived at the browser rather than what the origin sent.
  • For the races, measurement is not enough. Deterministic replay and stress testing are the tools, and they belong to Concurrency (Stress Testing: A Test That Passed Once Proves Nothing, Race Detectors: What They Find, and What They Structurally Cannot).
  • Track third-party embed error rates as a release-health signal when you turn isolation on. That is where the regression will show up, not in your own error stream (Release Health).
Slow device, slow network, large data, old tab
  • On a page with many third-party integrations, isolation is often simply unavailable at any acceptable cost. This is the common case, not the exception.
  • On a device with few cores, two threads contending on shared memory can be slower than one thread doing the work, because contention costs are real and parallelism is not (What Contention Actually Costs in Concurrency).
  • Availability differs by browser and version, and the exact header combination accepted has changed more than once. Feature-detect; never assume based on a browser name (Polyfills vs Transpilation).
  • In an embedded context — your app inside someone else's iframe — you do not control the top-level document, so you cannot be isolated no matter what your own headers say.
  • On a large dataset the payoff is largest, which is exactly when a torn read is hardest to reproduce and hardest to spot in the output.
What this costs
  • You trade third-party embeddability for a primitive. For a video editor that is obviously worth it; for a content site with ad revenue it is obviously not; in between it is a product decision, not an engineering one.
  • You trade JavaScript's single-threaded safety for real concurrency. Every hazard the language protected you from is now yours to reason about (Reasoning About Races: A Method, Not an Instinct).
  • You take on a second code path — isolated and non-isolated — that must both be tested, or the fallback will be broken when it is finally needed.
  • Splitting isolated routes onto their own origin adds deployment, routing and possibly authentication complexity, and cross-origin navigation between them costs a full document load (Micro Frontends).

Where this applies

Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.

  • SPEC-EVOLVINGThe gating has already changed once — SharedArrayBuffer was available unconditionally, was withdrawn after Spectre, and returned behind cross-origin isolation. COEP: credentialless, Document-Isolation-Policy and the origin-agent-cluster hint are all still moving, so treat the exact header combination as something to verify against current documentation rather than something to memorise.
  • BROWSER-SPECIFICSupport and behaviour differ by engine: Chromium shipped credentialless and Atomics.waitAsync first, Firefox and Safari arrived at the requirement on different timelines, and each browser surfaces COEP blocking differently in devtools — Chromium names the policy in the Network panel's initiator, while others report a generic load failure.
  • GENERALThe underlying reasoning is universal across browsers: a shared buffer plus a counting worker is a high-resolution timer, so the primitive is gated on process isolation rather than on timer coarsening. Every engine reached the same conclusion.

Where the depth lives

This domain teaches the browser-side mechanism and hands the rest off.

Performancecpu-performance
Domains that do not exist yet
  • Distributed Systems — a ring buffer between two threads and a replicated log between two nodes are the same publication problem with different failure modes: here you lose ordering, there you lose the message.