All posts
HLSStreamingEngineering21 Sep 2026 · 3 min read

Cross-origin isolation and video on the same page

JPJean Perez

window.crossOriginIsolated is a boolean with a cost attached. A document that turns it on gets SharedArrayBuffer back, which is what a multi-threaded FFmpeg build compiled to WebAssembly needs to hand frame buffers between worker threads without copying them. What the document pays for that is stricter rules on every cross-origin resource it loads from then on, video included, and that's the part worth understanding on its own.

What COEP restricts

Cross-Origin-Embedder-Policy: require-corp is the header that turns isolation on. Once it's set, the browser blocks any cross-origin resource, an image, a video, an HLS segment fetched by hls.js, unless that resource explicitly opts in. Opting in means one of two things: the response carries Cross-Origin-Resource-Policy: cross-origin (or same-site, if it fits), or it's requested in CORS mode (a fetch(), or an element with the crossorigin attribute) and the server answers with a matching Access-Control-Allow-Origin. Without either, the load fails, silently from the page's point of view, visible only in devtools as a blocked request.

There's a second, gentler mode: Cross-Origin-Embedder-Policy: credentialless. It grants the same isolation without requiring the resource to opt in, but it strips credentials, cookies, anything session-bound, from the request first. That's useful for a resource you don't control the headers of, a CDN-hosted stock video, a public thumbnail, but it's the wrong choice for anything that needs an authenticated fetch, which most private video storage does.

For a video pipeline this lands squarely on the delivery layer. An HLS manifest, a DASH segment, a poster image sitting in object storage: none of them are exempt just because they're media. Whatever serves them has to carry Cross-Origin-Resource-Policy or answer CORS correctly, or an isolated page simply won't render them.

A plain <video src="..."> pointed at another origin is a no-cors request by default, the same request an <img> tag makes, so COEP treats it like any other subresource: no CORP header, no playback. hls.js and dash.js load differently. They fetch the manifest and every segment with fetch() or XMLHttpRequest in CORS mode, so each request passes as long as the storage answers CORS correctly. A player library that already works across origins usually keeps working under isolation. A bare <video> or <img> tag is what breaks.

Why isolation and arbitrary remote media don't share a document

COOP and COEP headers are set on the response for a given navigation and apply to the whole document from the moment it's created. They can't be turned on or off partway through a client-side route change, because isolation belongs to the document as a whole.

That's a real constraint the moment an app has two different jobs living under one shell: a tool that needs SharedArrayBuffer for a multi-threaded codec, and other tools that just need to play back video from wherever a user's project happens to store it. The isolated tool can require every piece of cross-origin media it touches to carry the right header. The playback tools can't make that same demand of every video host a user might point them at. One document can't satisfy both. Isolate everything and video that doesn't carry CORP stops playing. Isolate nothing and the codec goes with it. The two requirements resolve by keeping the isolated tool and the general-purpose players in separate documents, each with the COOP and COEP posture its own job actually needs.

A short example from the shell

That's the constraint behind a single line in BeemMeUp's app shell: /ffmpeg-studio is the only route that isolates. Every other tool route plays back media from wherever a project's files live, and doesn't isolate at all. For a while, every tab switch between tools was a full document reload, because a client-side route change can't flip COOP/COEP for the tools that don't need it.

Only switches into or out of /ffmpeg-studio cross that boundary, so those are the only ones that need a full load:

// apps/web/lib/shell/shell-navigation.ts
function isIsolatedRoute(pathname: string): boolean {
  return pathname.startsWith('/ffmpeg-studio');
}

A switch checks whether the destination's isolation requirement matches the current document's actual window.crossOriginIsolated value, and only forces a fresh document load when it doesn't. Every other tool-to-tool switch stays client-side, because none of those tools ever asked the browser to isolate them in the first place.

The lesson generalizes past this one shell. A page that needs SharedArrayBuffer and a page that plays arbitrary cross-origin video are two different documents with two different obligations. Keeping them apart lets each one meet its own.