Skip to content
Blog

Machine Learning in Flutter Web: A Working Inference Pattern

Run ML models in a Flutter web app: the TensorFlow.js, ONNX Runtime Web, and Transformers.js landscape, plus asset loading, Web Worker inference, quantization, and caching.

Published on • November 11, 2026

AI Assistant

Flutter web compiles to JavaScript or Wasm, but the model still has to run somewhere — and “somewhere” means a browser ML runtime you drive through JS interop. This is the umbrella post for that setup: which runtime to pick, how to load a model from a Flutter app, how to keep inference off the UI thread, and what the multi-megabyte model file does to your page metrics. The TensorFlow Lite and ONNX Runtime paths get their own posts; here we wire the general pattern.

The options in one pass

TensorFlow.js is the default answer: it runs pre-trained models, converts Python TensorFlow models into a browser format, does transfer learning in the browser, and switches between WebGL and WebGPU backends. Flutter’s own Holobooth demo is a Flutter web UI sitting on TensorFlow.js — proof the combination ships. ONNX Runtime Web covers the PyTorch side: export once, run in the browser (see the ONNX post). Transformers.js pulls Hugging Face models into the page, and MediaPipe handles vision tasks.

Pick by model origin, not preference: TensorFlow models go to TensorFlow.js, PyTorch exports go to ONNX, and a .tflite file has to be converted before it runs — that is the TensorFlow Lite post. The integration layer below is identical for all of them.

Load the model and call it through a bridge

Declare the model in your Flutter assets, then keep one thin JS bridge file that Dart can reach through dart:js_interop:

# pubspec.yaml
flutter:
  assets:
    - assets/models/model.json
    - assets/models/group1-shard1of1.bin
import 'dart:js_interop';
import 'dart:js_interop_unsafe';
import 'package:web/web.dart' as web;

Future<Float32List> runInference(Float32List input) async {
  final bridge = web.window.getProperty('inference'.toJS)! as JSObject;
  final pending = bridge.callMethod('run'.toJS, input.toJS)! as JSPromise;
  final output = await pending.toDart as JSFloat32Array;
  return output.toDart;
}

The inference global lives in a plain script under web/ that loads the runtime and the model once, so session creation happens a single time instead of per call.

Keep inference off the main thread

A model run on the UI thread drops frames — Flutter’s rendering pipeline is already using the main thread. Move the model into a Web Worker and talk to it with postMessage, copying small results and transferring large buffers:

// web/inference-worker.js
self.onmessage = async ({ data: { id, input } }) => {
  self.model ??= await self.tf.loadGraphModel('/assets/models/model.json');
  const output = self.model.execute(input);
  self.postMessage({ id, data: await output.data() });
};

// main thread
const worker = new Worker('/inference-worker.js');
worker.postMessage({ id: 1, input: tensorData }, [tensorBuffer]);

If you need zero-copy sharing between worker and page, SharedArrayBuffer is available — but only under cross-origin isolation (COOP and COEP headers). Without those headers, plan on structured-clone copies and keep payloads small.

Give the worker a timeout and a recovery path: a model that throws mid-session should not leave the UI thread waiting forever. Recreate the worker on failure and surface a retry affordance to the user rather than freezing the feature.

Budget the model like you budget the bundle

A 30 MB float32 model on a mobile network is a page-load event, not a download. Three levers matter:

# versioned model files: cache them forever
location /assets/models/ {
  add_header Cache-Control "public, max-age=31536000, immutable";
}

Quantize first: float32 to uint8 typically cuts the file 4x with a small accuracy cost. Cache aggressively second — the model should be a repeat-visit non-event. Warm up third: run one dummy inference right after load so the first real request does not pay for shader compilation and graph setup.

Ship it and measure

flutter run -d chrome gives you the debug toolchain for wiring work; flutter build web --release is what your users get, with tree-shaking and minification applied. Either way, load the model after first paint — a multi-MB model fetched during startup shows up directly in LCP and INP, and no amount of interop fixes a slow first frame:

flutter run -d chrome          # wiring and debugging
flutter build web --release    # tree-shaking + minified output

Log the numbers that matter: model download time, time-to-first-inference (the warm-up run), and per-inference latency inside the worker. If inference latency dominates, the fix is a smaller or quantized model, not faster Dart.

Ship a degraded path as well: if the model download fails or times out, fall back to a non-ML feature rather than a spinner that never resolves — the model is an enhancement to the page, not the page itself.

Takeaway: Choose the runtime by model origin, load the model through a thin JS bridge with dart:js_interop, run inference in a Web Worker, and treat quantization plus long-lived caching as part of the feature, not an optimization for later.

Sources