ONNX Runtime Web: Running Models in a Flutter Web App
Export once from PyTorch or TensorFlow and run it in the browser with onnxruntime-web from Flutter: ORT format, execution providers, wasm paths, threads, and JS interop.
Published on • November 11, 2026
AI Assistant

ONNX is the export format that ends the framework argument: PyTorch, TensorFlow, scikit-learn — export once to .onnx, and onnxruntime-web runs it in the browser. For a Flutter web app that means one JS dependency, one session created at startup, and a thin dart:js_interop layer. This post walks the path end to end, from install to reading output tensors in Dart.
Install and create the session
Add the package with npm, because onnxruntime-web ships JS plus .wasm binaries that your build must copy:
npm install onnxruntime-web
Session creation is where most setup lives. Point wasmPaths at the bundled .wasm files and decide the thread count before the first session exists:
// web/onnx.js
import * as ort from 'onnxruntime-web';
ort.env.wasm.wasmPaths = '/ort/'; // where the .wasm files were copied
ort.env.wasm.numThreads = self.crossOriginIsolated ? 4 : 1;
const session = await ort.InferenceSession.create('/models/classifier.ort', {
executionProviders: ['webgpu', 'wasm'],
});
Create the session once and reuse it — initialization is expensive, run() is the hot path. Two rules on wasmPaths: the .wasm files must come from the same build version as the JS bundle (a mismatch fails to initialize), and numThreads above 1 only engages when the page is cross-origin isolated (COOP and COEP headers); otherwise ONNX Runtime falls back to single-threaded execution on its own, and forcing 1 just makes that explicit.
Prefer the ORT format for the web
ONNX files work, but the ORT format exists for size-constrained targets like web: it is optimized at conversion time, parses faster, and peaks at lower memory. Convert with the script shipped in the Python package:
pip install onnxruntime
python -m onnxruntime.tools.convert_onnx_models_to_ort models/classifier.onnx
# models/classifier.ort + required_operators.config
The .ort extension is enough — InferenceSession.create infers the format from the file name or from a marker in the bytes, so no extra option is needed.
Choose execution providers deliberately
executionProviders is an ordered list, and ONNX Runtime walks it until a provider accepts an op. The four web providers are wasm (the CPU baseline), webgl, webgpu, and webnn. WASM supports every ONNX operator; the GPU providers only cover a subset, so always keep wasm last in the list as the safety net:
async function pickProviders() {
if (navigator.gpu && (await navigator.gpu.requestAdapter())) {
return ['webgpu', 'wasm'];
}
return ['webgl', 'wasm'];
}
The requestAdapter() check matters: navigator.gpu existing does not guarantee an adapter, and a WebGPU session that fails at creation should degrade rather than break the feature. One more trade-off: ort.env.wasm.proxy = true offloads compute to a Web Worker and keeps the UI responsive, but it cannot be combined with the WebGPU EP (GPU buffers are not transferable) and it does not work under a strict CSP.
Feed tensors and read outputs
Input names come from the model, not from you — check session.inputNames once and build tensors to match, with the exact dims:
const tensor = new ort.Tensor('float32', inputFloat32Array, [1, 3, 224, 224]);
const results = await session.run({ input: tensor });
const probs = results.output.data; // Float32Array, read-only view
console.log(session.outputNames, probs[0]);
run() resolves with an object keyed by output name; results.output.data is typed-array backed, so copying into a Dart Float32List is a straight sublistView. Preprocessing — resize, normalize, NCHW layout — belongs on the JS side next to the tensor, exactly as in the TensorFlow Lite post.
Reach it from Dart
Inference is headless, so no platform view is involved: one bridge global, one async call, typed arrays crossing the boundary:
import 'dart:js_interop';
import 'dart:js_interop_unsafe';
import 'package:web/web.dart' as web;
Future<Float32List> classify(Float32List pixels, List<int> dims) async {
final bridge = web.window.getProperty('onnxBridge'.toJS)! as JSObject;
final pending =
bridge.callMethod('run'.toJS, pixels.toJS, dims.toJS)! as JSPromise;
final output = await pending.toDart as JSFloat32Array;
return output.toDart;
}
From here the rest of the ML inference post applies unchanged: quantize the model, cache the .ort file under a versioned URL with a long-lived Cache-Control, warm it up after load, and move repeated calls into a worker when latency shows up in your frame timings.
Takeaway: Export to ONNX, convert to the .ort format, create one session with an ordered provider list ending in wasm, and drive session.run from Dart — detection and fallback decide between WebGPU speed and WASM compatibility.