Handling Browsers Without SharedArrayBuffer
This page answers one task: your WebAssembly module uses threads, which need SharedArrayBuffer, but some of your pages or users cannot
have it — serve a working single-threaded variant to them without slowing down everyone else.
Prerequisites
- [ ] A threaded build (Rust with
wasm-bindgen-rayon, or C/C++ with Emscripten-pthread). - [ ] The ability to produce a second, single-threaded build from the same source.
- [ ] Knowledge of which pages are cross-origin isolated and which are not.
Why SharedArrayBuffer is not always there
WebAssembly threads are built from Web Workers sharing one WebAssembly.Memory created with shared: true, whose buffer is a
SharedArrayBuffer. Browsers only expose shared memory to cross-origin isolated pages — pages served with COOP and COEP headers — for
the Spectre-related reasons explained in
Spectre and cross-origin isolation for Wasm.
A page that is not isolated cannot create a shared memory, and a threaded module that imports one fails to instantiate.
Plenty of pages cannot be isolated: ones that embed third-party iframes without COEP support, use popups that rely on window.opener,
or load resources from origins that send neither CORS nor CORP headers. A widget embedded in other people’s sites inherits whatever its
host page does. So a library or app that uses threads needs a plan for the non-isolated case, and the robust plan is a second build.
Step 1 — detect reliably
The two checks that matter are whether the page is isolated and whether the constructor exists:
export function canUseThreads() {
return typeof SharedArrayBuffer === "function"
&& self.crossOriginIsolated === true
&& typeof Atomics === "object";
}
Checking crossOriginIsolated matters because some browsers expose the SharedArrayBuffer constructor without isolation for compatibility
but refuse to share it across workers. To be certain, try creating a shared memory, which is exactly what the threaded build will do:
function canCreateSharedMemory() {
try {
const m = new WebAssembly.Memory({ initial: 1, maximum: 1, shared: true });
return m.buffer instanceof SharedArrayBuffer;
} catch { return false; }
}
More on detection in detecting cross-origin isolation at runtime.
Step 2 — produce two builds from one source
For Rust, the threaded build needs nightly, the atomics target features and a rebuilt standard library; the single-threaded build is an ordinary stable build. Gate the parallel code behind a feature so the same source compiles both ways:
[features]
default = []
threads = ["dep:wasm-bindgen-rayon", "dep:rayon"]
#[cfg(feature = "threads")]
fn process(chunks: &mut [Chunk]) { use rayon::prelude::*; chunks.par_iter_mut().for_each(work); }
#[cfg(not(feature = "threads"))]
fn process(chunks: &mut [Chunk]) { chunks.iter_mut().for_each(work); }
# single-threaded (stable)
wasm-pack build --release --target web --out-dir pkg-st
# threaded (nightly)
RUSTFLAGS="-C target-feature=+atomics,+bulk-memory,+mutable-globals" \
rustup run nightly wasm-pack build --release --target web --out-dir pkg-mt -- \
--features threads -Z build-std=panic_abort,std
For C and C++ with Emscripten, the difference is the -pthread flag on compile and link; build twice into separate directories. The
threaded setup in detail is in
building Rust Wasm with threads using Rayon.
Step 3 — load the matching build
Choose at runtime and import dynamically, so each user downloads one build:
import { canUseThreads } from "./detect.js";
export async function loadEngine() {
if (canUseThreads()) {
const m = await import("./pkg-mt/engine.js");
await m.default();
await m.initThreadPool(Math.min(navigator.hardwareConcurrency, 8));
return { kind: "threaded", api: m };
}
const m = await import("./pkg-st/engine.js");
await m.default();
return { kind: "single", api: m };
}
Both packages export the same functions, so callers use api.process(...) without caring which they got. Report kind to your monitoring
so you know what share of users run each.
Step 4 — keep the single-threaded build off the main thread
Without threads, a long computation in the single-threaded build blocks whatever thread it runs on. On the main thread that freezes the page,
which is worse than slow. Run the whole single-threaded engine inside one dedicated worker — workers do not need shared memory — and talk to
it with postMessage and transferable buffers:
// engine.worker.js
import init, * as engine from "./pkg-st/engine.js";
const ready = init();
self.onmessage = async ({ data }) => {
await ready;
const out = engine.process(data.input);
self.postMessage(out, [out.buffer]);
};
The user keeps a responsive page; the work simply runs on one core instead of several. The pattern is covered in keeping the UI responsive during long Wasm tasks.
Step 5 — decide whether to isolate more pages
If monitoring shows most users on the single-threaded build, the better fix may be isolating the pages where the feature lives, rather than
optimizing the fallback. Often only one route needs threads — an editor, a processing page — and it can be isolated even if the rest of the
site cannot. Isolation with COEP: credentialless is easier to adopt than require-corp because ordinary third-party images and scripts
keep working. The fallback then serves only embedded or legacy contexts.
Designing work so both builds stay useful
The single-threaded build is not just a slower copy; how the work is structured decides whether it is merely slower or genuinely bad. A threaded design that splits a job into many small tasks and synchronizes between them often degrades well — run sequentially, the tasks simply execute one after another. A design that relies on threads for responsiveness degrades badly: if one thread is meant to keep handling input while others compute, the single-threaded build has nobody to handle input, and the dedicated worker becomes the only protection.
Three habits keep both builds healthy. Express parallel work as independent chunks with a clear merge step, which runs correctly in any order and on any number of cores, including one. Report progress per chunk, so the single-threaded build can still update a progress bar between chunks and the user sees movement rather than a frozen indicator. And make the chunk size a parameter rather than a constant: the threaded build wants enough chunks to keep every core busy, while the single-threaded build in a worker wants fewer, larger chunks to minimize overhead and message traffic.
With that structure, the difference between the builds is the number of chunks in flight at once — one or many — and everything else, including correctness, cancellation and progress reporting, is shared code that both builds exercise in every test run.
Interpreting the split in production
The ratio between the two builds in your monitoring tells you something about your deployment, not just your users. A healthy setup where
the feature’s page is isolated should show the threaded build for nearly all users of modern browsers, with the single-threaded build
appearing for embedded contexts and the occasional privacy-hardened browser. A sudden rise in single-threaded loads usually means isolation
broke — a new third-party resource without CORP, a CDN that dropped the headers, a redirect that lost them — and is worth an alert. That
makes the kind metric a cheap canary for a configuration problem that would otherwise only appear as “the editor got slower” in user
feedback.
Expected output
On an isolated page:
engine: threaded (8 workers) crossOriginIsolated=true
On a non-isolated page, the same code loads:
engine: single (1 worker) crossOriginIsolated=false
and the feature works in both, at different speeds.
Gotchas
- The threaded build loads on a non-isolated page and fails. Detection checked only
typeof SharedArrayBuffer. CheckcrossOriginIsolatedor try creating a shared memory. - Both builds downloaded. A static import of either package. Import both dynamically.
- APIs drift between builds. A function added only behind the
threadsfeature. Keep the public API identical and test both builds. - Single-threaded build runs on the main thread. Long tasks freeze the page. Put it in a worker.
Performance note
For an image pipeline on an eight-core laptop, the threaded build processed a batch in 210 ms and the single-threaded build in one worker in 690 ms. On a four-core phone the gap was smaller — 520 ms against 1,240 ms — because fewer cores were available to the threaded build. In both cases the page stayed responsive, which mattered more to users than the raw difference.
Frequently Asked Questions
Can I polyfill SharedArrayBuffer? No. Shared memory between threads cannot be emulated with message passing at anything like the same cost, and a polyfill would not satisfy Wasm’s shared memory import.
Do Node, Deno and Bun need this?
No — server-side runtimes expose SharedArrayBuffer without isolation, so the threaded build works there unconditionally.
Can a page become isolated after it has loaded? No. Isolation is decided by the response headers of the top-level document, so the choice of build is fixed for the life of the page.
Is the single-threaded build smaller? Usually slightly, because it omits the thread pool, atomics and synchronization code. The difference is a few percent.
Can I use Atomics without threads?
Atomics works on non-shared memory too, but without other threads there is nothing to synchronize with. Single-threaded builds should not
depend on it.
Related
- Configuring COOP/COEP headers for SharedArrayBuffer — making a page isolated.
- Building a Wasm thread pool — what the threaded build runs.
- Porting pthreads code with Emscripten — the C/C++ threaded build.
- Feature detecting Wasm at startup — detection in general.
← Back to Polyfill Alternatives & Fallbacks