DEV Community

hao jia
hao jia

Posted on

onnxruntime-web numThreads silently falls back to 1 without COOP/COEP (6318 ms vs 2133 ms)

![ ](https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/cOur frontends sit behind a shared gateway, and I don't control its response headers. Multi-threaded WASM needs the page to be cross-origin isolated, and that depends on two headers I can't guarantee. So before our team leaned on ort.env.wasm.numThreads, I wanted to see what happens when the headers go missing. I expected an error. What I got was a page that ran three times slower and said so only in the console.

The test page

I served the same static files from two local servers. One sends Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp, and the other sends neither, so crossOriginIsolated is true on the first and false on the second. The page loads onnxruntime-web 1.27.0 and runs ISNet INT8 on a 1024×1024 input. The machine was an Apple M4 / 16 GB on a Chromium 149 open-source build, and the number I compare is the median steady-state run.

With isolation and numThreads = 4, steady-state inference was 2,133 ms. Without isolation and the same numThreads = 4, it was 6,318 ms, which matches the single-thread run on the isolated page (6,313 ms). The session was created, inference finished, the output was fine. No exception anywhere. ORT printed two warnings and carried on:

  • env.wasm.numThreads is set to 4, but this will not work unless you enable crossOriginIsolated mode
  • WebAssembly multi-threading is not supported in the current environment. Falling back to single-threading.

WebGPU on the non-isolated page was 355 ms against 359 ms on the isolated one, so only the WASM path is affected. But that's exactly the path your users land on when their browser has no GPU adapter.

Repro page without COOP/COEP: crossOriginIsolated is false, ORT prints two warnings, and one run takes about three times as long

The screenshot is the non-isolated page. Read the crossOriginIsolated=false line first, then the two ORT warnings under it, then the run time. It's a single run, so the number on screen won't match the median exactly; what matters is that it sits near the single-thread figure, not the 4-thread one.

Why this would have shipped

Our monitoring collects exceptions and failed requests. A console warning shows up in neither, and a 3x slowdown on one code path won't fail a functional test. The failure only exists as a performance number, and only when a header got dropped somewhere upstream. That's a bad combination for a team that deploys through infrastructure it doesn't own.

For a reference, I used ImgIng (https://imging.ai/), which ships the same ORT build, on the same M4 Mac with Chromium 149. Its responses carry COOP same-origin and COEP require-corp, which is what makes crossOriginIsolated true there. Its cutout code picks Math.min(4, hardwareConcurrency) threads only when the page is isolated and not on mobile, and sets 1 otherwise. The upscaling worker uses a slightly different formula, min(4, cores - 1), which leaves a core for the main thread. The point for me was that the code decides the thread count itself instead of asking for 4 and hoping.

What I changed

I now set the thread count from crossOriginIsolated and report the degraded case myself:

const WASM_THREADS = 4;

function configureOrtThreads(env) {
  const isolated = globalThis.crossOriginIsolated === true;
  env.wasm.numThreads = isolated ? WASM_THREADS : 1;
  if (!isolated) {
    console.error('[ort] page is not cross-origin isolated; WASM will run on 1 thread');
  }
  return { isolated, threads: env.wasm.numThreads };
}
Enter fullscreen mode Exit fullscreen mode

I ran this on both servers. The isolated page returned { isolated: true, threads: 4 } with a clean console. The non-isolated page returned { isolated: false, threads: 1 }, and the two ORT warnings were gone, because ORT is no longer asked for something it can't do. The only line left was our own error, which is the one our monitoring can pick up (in production that console.error becomes a call to our reporter). Next on my list is a deploy check that requests the app shell and fails if either header is missing.

What I haven't measured yet is the cost on our side. require-corp blocks cross-origin images and scripts that don't opt in with CORP or CORS, and our pages load plenty of those. That audit is the real work, and none of these numbers cover it.

If you use numThreads above 1, open your production page, type crossOriginIsolated into the console, and check that it says true.
fq78zr90b8losdduf1f.png)

Top comments (1)

Collapse
 
mihai_leanzero profile image
Mihai Perdum •

The require-corp cost you flagged as unmeasured is the part I'd worry about more than the thread count itself, since that's the one that can break rendering outright rather than just degrade performance quietly.

One thing worth checking on the deploy check you're planning: does it request the app shell through the same path real users hit, or straight from origin? A CDN or edge cache in front of the gateway can strip or fail to forward COOP/COEP on cached responses even when the origin server sends them correctly, so a check that curls origin directly would pass while the page users actually get is still non-isolated. Worth hitting it through the CDN hostname specifically, ideally with a cache-busting query param so you're not just reading a stale cached response that happened to carry the headers from before someone changed the config.