Four ONNX-in-the-Browser Deployment Traps That Only Break in Production
Four ONNX-in-the-Browser Deployment Traps That Only Break in Production
Search
Ask the AI

Four ONNX-in-the-Browser Deployment Traps That Only Break in Production

Running an ONNX model in the browser works fine locally and then breaks in production. I have hit all four of the traps below. What they share is that the error message points somewhere other than the actual cause. Each one gets the real error, the root cause, and the fix.

Trap 1: shipping the .wasm without the .mjs

Error:

no available backend found.
ERR: [wasm] TypeError: Failed to fetch dynamically imported module:
https://example.com/models/ort-wasm-simd-threaded.mjs

Cause: ONNX Runtime Web loads WASM in two stages. ort-wasm-simd-threaded.wasm is the compute binary, but a glue module of the same name with an .mjs extension is what instantiates it, fetched at runtime as a dynamic ESM import. Deploy only the .wasm and the runtime finds no usable backend at all.

It is easy to miss because “model + runtime js + wasm” intuitively sounds like the complete set.

Fix: deploy the .mjs matching your chosen backend from the npm package’s dist/, at a version that strictly matches ort.min.js. Confirm you have not mismatched versions by comparing the sha256 of your .wasm against the same version on a CDN.

Trap 2: nginx does not know about .mjs

Symptom: the file is definitely deployed, curl returns 200, and the browser still shows the same error.

Cause: nginx 1.24’s mime.types has no entry for .mjs. The file goes out as application/octet-stream, and browsers refuse to execute a module script served with a non-JavaScript type. Both .js and .wasm are in the default map; .mjs alone is not.

$ grep -nE "wasm|javascript" /etc/nginx/mime.types
8:    application/javascript     js;
55:   application/wasm           wasm;
# no mjs

Fix: add a rule. Do not reach for an inner types { } block — that replaces the inherited map rather than extending it, which quietly breaks other types. default_type is safer because it only applies when the map has no entry:

location ~* \.mjs$ {
    default_type text/javascript;
    expires 1y;
    add_header Cache-Control "public, max-age=31536000, immutable" always;
}

Verifying: a 200 is not enough — you have to check the type.

curl -sI https://example.com/models/ort-wasm-simd-threaded.mjs \
  | grep -i content-type
# must be text/javascript or application/javascript

Trap 3: the config arrives after the script that reads it

Symptom: the feature’s entry point is greyed out, with no error anywhere.

Cause: this one is WordPress-flavoured, but the same shape appears in any bundling system with declared dependencies. wp_localize_script attaches config to one specific handle, and the config <script> is printed immediately before that handle. If the module that reads the config is a dependency of that handle, the dependency necessarily loads first — at which point the config does not exist yet.

// read at module top level - window.myConfig is still undefined here
var CFG = window.myConfig || {};
var PATHS = CFG.paths || {};
window.MyModule = {
  available: !!(PATHS.runtime && PATHS.model)   // permanently false
};

Measured on the real page: ai.js at byte offset 70843, the config at 70998, ui.js at 75007. The dependency beat the config by 155 bytes.

Fix: do not read config at load time — read it at call time, and turn the stored boolean into a getter so it is evaluated at the moment it is accessed:

function paths() {
  return (window.myConfig || {}).paths || {};
}
Object.defineProperty(api, 'available', {
  get: function () {
    var p = paths();
    return !!(p.runtime && p.model && window.WebAssembly);
  }
});

Trap 4: multithreading requires cross-origin isolation

Symptom: you set numThreads > 1 and get SharedArrayBuffer is not defined, or a silent fall back to one thread.

Cause: WASM threads need SharedArrayBuffer, which requires the page to be cross-origin isolated. The server must send both:

Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

The catch is that require-corp affects every cross-origin resource on the page: third-party fonts, analytics, ads, CDN images. All of them must then carry Cross-Origin-Resource-Policy or go through CORS, or they are blocked outright. For an ordinary content site, adding those two headers site-wide to make inference somewhat faster is rarely a good trade.

Fix: accept one thread and set it explicitly, rather than letting the runtime try and fall back:

ort.env.wasm.numThreads = 1;
ort.env.wasm.simd = true;          // SIMD needs no isolation - keep it
ort.env.wasm.wasmPaths = MODEL_DIR;

SIMD and threading are separate features. SIMD requires no extra headers and should stay on.

Trap 5: large transfers truncate silently

Symptom: InferenceSession.create() throws a protobuf parse error, or simply hangs. The file size on the server looks right and curl returns 200.

Cause: models are routinely tens or hundreds of megabytes. Any interruption anywhere on the path — a dropped ssh session, a CDN timeout, a proxy cutting the connection — can leave a file that exists at the wrong size while HTTP still reports 200. Deploying an 84 MB model here, my own verification curl had a 180-second timeout and returned success after reading 25 MB.

script reported:  isnet-anime-fp16.onnx -> 200 25524967B
actually on disk: 88070593 bytes

Anyone reading just that 200 would conclude the deploy worked.

Fix: transfer large files with rsync, which resumes and checksums; and after deploying, compare hashes, not sizes:

rsync -h --partial --inplace --timeout=120 \
  -e "ssh -o ServerAliveInterval=15" "$src" "$remote:$dest"

# verify after deploy - allow generous time; 84 MB over a bad link takes minutes
LOCAL=$(shasum -a 256 model.onnx | cut -d" " -f1)
REMOTE=$(curl -s --max-time 900 "$BASE/model.onnx" | shasum -a 256 | cut -d" " -f1)
[ "$LOCAL" = "$REMOTE" ] || echo "incomplete transfer"

One easily missed detail: the timeout in your verification script is itself a source of bugs. That 180 seconds was not a network problem — it was me not allowing enough time, and curl exits 0 after a timeout while reporting however many bytes it managed.

A note on model size

With those traps out of the way there is still a decision to make: what precision to ship. It directly sets how much a first-time visitor downloads.

Three versions of the same segmentation model:

fp32   167.9 MB
fp16    84.0 MB      WASM load  79 ms
int8    42.1 MB      WASM load 337 ms

Counter-intuitively, the smallest file loads slowest — a quantised graph needs its quantisation parameters parsed and dequantisation nodes inserted, and that cost is paid at session creation. Inference time is comparable across all three (all around 7 seconds on single-threaded WASM), so int8’s only real advantage is download size.

And int8 can cost a great deal: in my own measurements it degraded badly on inputs the model was unsure about. If your model will meet varied real-world input, fp16 is usually the safer choice — twice the size of int8, with essentially no accuracy loss.

A pre-deploy checklist

What unites these four is that a local dev environment will not expose any of them — dev servers ship correct MIME maps, bundlers resolve dependency order for you, and static asset folders get copied wholesale. So the checks have to run against the real deployment:

# check status code AND content type for every runtime asset
for f in ort.min.js ort-wasm-simd-threaded.wasm ort-wasm-simd-threaded.mjs; do
  curl -sI "$BASE/$f" | grep -iE "^HTTP/|^content-type"
done

# verify large files byte-for-byte; a truncated transfer does not error
curl -s "$BASE/model.onnx" | sha256sum

That last one matters more than it looks. I have repeatedly seen curl return 200 with a truncated body over a flaky link. Only a hash proves the transfer was complete — a status code does not.

Leave a Reply

Scroll down