AI/ML

Running AI Models in Flutter: Local LLM Integration Guide

Muhammad Shakil Muhammad Shakil
Mar 11, 2026
14 min read
Running AI Models in Flutter — local LLM integration with TFLite and llama.cpp
Back to Blog

A client asked me last year to add a "smart reply" feature to their messaging app. The brief was straightforward — call OpenAI's API, generate three suggested responses, show them as chips below the input field. I built it in two days. Worked beautifully in testing.

Then we shipped. Users in rural areas of Pakistan complained about 4-second response times. Users on hotel WiFi got flat-out timeouts. And our monthly API bill after just 5,000 active users? $200. For suggested replies. I spent another two days adding retry logic, response caching, and graceful degradation for offline scenarios. That's when it hit me — what if the model just ran on the phone itself?

That question sent me down a rabbit hole that lasted months. I tried TensorFlow Lite, wrestled with llama.cpp through Dart FFI, spent weekends profiling memory usage on budget Android phones. After all of that, I've now shipped three production Flutter apps with on-device AI features. No cloud dependency. Sub-second responses. Zero API costs.

I'm going to walk you through everything I learned — the stuff that actually works, the approaches I abandoned, and the real performance numbers nobody shares in tutorials.

Why I Stopped Using Cloud APIs for Everything

I want to be upfront — cloud APIs like OpenAI, Gemini, and Claude are phenomenal for complex reasoning tasks. If your app needs GPT-4-level intelligence, you need the cloud. No mobile chip on earth is running 175 billion parameters. That's just physics.

But most AI features in mobile apps don't need GPT-4. When I broke down the AI functionality across my projects, roughly 70% of them worked perfectly fine with small, local models:

The Cost That Changed My Mind

My banking app project used Firebase Cloud Functions as the backend. API costs for AI features were hitting $200/month with just 5,000 users. After moving smart-reply generation to on-device inference, that cost dropped to exactly zero. The model ships inside the app binary.

The other pain point was latency. Cloud API calls averaged 800ms-2s depending on network conditions. My Flutter performance guide talks about keeping UI interactions under 100ms. Waiting two seconds for a "suggested reply" feature felt broken to users — they'd already typed their response before the suggestions appeared.

On-device inference brought that down to 150-300ms depending on the model. Not instant, but fast enough that suggestions appeared while users were still thinking about what to type.

Setting Up TensorFlow Lite in Flutter

For classification tasks, object detection, and any ML workload that doesn't involve text generation, tflite_flutter is the tool I reach for first. It wraps the native TFLite runtime for both iOS and Android, and the API is surprisingly clean for what's essentially a bridge to C++.

Adding tflite_flutter to Your Project

dependencies:
 flutter:
 sdk: flutter
 tflite_flutter: ^0.11.0
 tflite_flutter_helper: ^0.4.0 # optional, but useful for image preprocessing

You'll also need the native TFLite libraries. On Android, tflite_flutter pulls them automatically through Gradle. On iOS, you need to add the TensorFlowLiteC pod — the package README walks you through it, but here's the short version:

# ios/Podfile — add this inside your target block
pod 'TensorFlowLiteC', '~> 2.14.0'

Loading and Running a Model

Here's how I load and run inference on a text classification model. This example classifies user messages into categories (question, complaint, feedback) for routing in a support app:

import 'package:tflite_flutter/tflite_flutter.dart';

class TextClassifier {
 late Interpreter _interpreter;
 bool _isReady = false;

 Future<void> loadModel() async {
 try {
 _interpreter = await Interpreter.fromAsset(
 'models/text_classifier.tflite',
 options: InterpreterOptions()..threads = 4,
 );
 _isReady = true;
 } catch (e) {
 // Model failed to load — fall back to cloud API
 _isReady = false;
 }
 }

 List<double> classify(List<int> tokenizedInput) {
 if (!_isReady) throw Exception('Model not loaded');

 var input = [tokenizedInput];
 var output = List.filled(1 * 3, 0.0).reshape([1, 3]);

 _interpreter.run(input, output);
 return output[0]; // [questionProb, complaintProb, feedbackProb]
 }

 void dispose() => _interpreter.close();
}

Where to Put Model Files

Drop your .tflite file in assets/models/ and declare it in pubspec.yaml under flutter: assets:. On Android, the file gets bundled into the APK. On iOS, it goes into the app bundle. Keep models under 50MB to avoid bloating your download size — anything larger, consider downloading the model on first launch instead.

The InterpreterOptions()..threads = 4 part matters more than you'd think. On a Pixel 7, going from 1 thread to 4 threads cut inference time from 85ms to 23ms for my text classifier. But going above 4 threads actually made it slower — the overhead of thread coordination wasn't worth it for a small model. I wrote about similar performance optimization patterns in a separate guide.

Running LLMs On-Device with llama.cpp

TFLite is great for classification, but when I needed actual text generation — smart replies, chat features, text summarization — I needed a different approach entirely. TFLite doesn't support autoregressive transformer models out of the box. That's where llama.cpp comes in.

llama.cpp is a C/C++ library that runs quantized LLMs with minimal dependencies. No Python, no PyTorch, no CUDA required. Just pure C++ that compiles everywhere — including ARM processors in phones. It's become the backbone of nearly every "run AI locally" project, and for good reason.

What GGUF Models Are (and Why They Matter)

When I first tried running a language model on a phone, I grabbed a 14GB Llama 2 model and wondered why it crashed immediately. Obviously — 14GB doesn't fit in 6GB of RAM. The solution is quantized models in GGUF format.

GGUF (GPT-Generated Unified Format) is the file format llama.cpp uses for quantized models. "Quantized" means the model weights are stored in lower precision — instead of 16-bit floats, you use 4-bit or 8-bit integers. A 7B-parameter model that's 14GB in full precision shrinks to roughly 4GB at INT4 quantization. That's the difference between "crashes on launch" and "runs on a midrange phone."

You can grab pre-quantized GGUF models from HuggingFace. I typically use models from TheBloke's collection — they're well-tested and come in multiple quantization levels.

Bridging llama.cpp to Dart with FFI

There's no official Flutter package for llama.cpp (as of March 2026). You need to bridge it yourself using Dart FFI. This took me a solid weekend to get right the first time, but the pattern is reusable across projects.

First, compile llama.cpp as a shared library for each platform. Here's the Android NDK approach:

# Clone and build llama.cpp for Android
git clone https://github.com/ggerganov/llama.cpp
cd llama.cpp
mkdir build-android && cd build-android

cmake .. \
 -DCMAKE_TOOLCHAIN_FILE=$NDK_HOME/build/cmake/android.toolchain.cmake \
 -DANDROID_ABI=arm64-v8a \
 -DANDROID_PLATFORM=android-24 \
 -DBUILD_SHARED_LIBS=ON

make -j$(nproc)

Then create your Dart FFI bindings:

import 'dart:ffi';
import 'dart:io';

typedef LlamaInitNative = Pointer<Void> Function(Pointer<Utf8> modelPath);
typedef LlamaInit = Pointer<Void> Function(Pointer<Utf8> modelPath);

typedef LlamaGenerateNative = Pointer<Utf8> Function(
 Pointer<Void> context,
 Pointer<Utf8> prompt,
 Int32 maxTokens,
);
typedef LlamaGenerate = Pointer<Utf8> Function(
 Pointer<Void> context,
 Pointer<Utf8> prompt,
 int maxTokens,
);

class LlamaBridge {
 late DynamicLibrary _lib;
 late LlamaInit _init;
 late LlamaGenerate _generate;
 Pointer<Void>? _context;

 LlamaBridge() {
 _lib = Platform.isAndroid
 ? DynamicLibrary.open('libllama.so')
 : DynamicLibrary.open('llama.framework/llama');

 _init = _lib.lookupFunction<LlamaInitNative, LlamaInit>('llama_init');
 _generate = _lib.lookupFunction<LlamaGenerateNative, LlamaGenerate>(
 'llama_generate',
 );
 }

 bool loadModel(String modelPath) {
 final pathPtr = modelPath.toNativeUtf8();
 _context = _init(pathPtr);
 calloc.free(pathPtr);
 return _context != null && _context != nullptr;
 }

 String generate(String prompt, {int maxTokens = 128}) {
 if (_context == null) throw Exception('Model not loaded');
 final promptPtr = prompt.toNativeUtf8();
 final result = _generate(_context!, promptPtr, maxTokens);
 calloc.free(promptPtr);
 return result.toDartString();
 }
}

FFI Is Powerful but Unforgiving

Memory management with Dart FFI is manual. If you forget to free native memory, you'll get leaks that crash your app after extended use. I learned this the hard way during a demo — the app worked great for 10 minutes, then OOM-killed itself. Always free your pointers, and consider wrapping native resources in a Finalizer so Dart's GC cleans up automatically.

Platform Channels as an Alternative

If wrestling with FFI bindings sounds painful (it is, honestly), platform channels offer a friendlier path. You write the native inference code in Swift/Kotlin, then call it from Dart through a message-passing channel.

// Dart side — calling native inference
class NativeLlmChannel {
 static const _channel = MethodChannel('com.flutterstudio/llm');

 static Future<String> generate(String prompt) async {
 final result = await _channel.invokeMethod<String>('generate', {
 'prompt': prompt,
 'maxTokens': 128,
 'temperature': 0.7,
 });
 return result ?? '';
 }

 static Future<bool> loadModel(String modelPath) async {
 return await _channel.invokeMethod<bool>('loadModel', {
 'path': modelPath,
 }) ?? false;
 }
}

The downside? You're writing the same logic twice — once in Swift for iOS, once in Kotlin for Android. For my banking app, we went with FFI because we wanted a single codebase. For a simpler project, platform channels are perfectly fine.

You could also look at flutter_rust_bridge if your team knows Rust. It generates type-safe Dart bindings from Rust code automatically, and Rust's memory safety guarantees eliminate the pointer bugs that plague raw FFI.

Offloading Inference to Dart Isolates

Here's a mistake I made on my first on-device AI app: I ran inference on the main isolate. The model loaded fine, generated text correctly, but the UI froze for 2-3 seconds during each inference call. Buttons stopped responding, scroll stalled, the whole app felt dead.

The fix is Dart isolates. Isolates are Dart's version of threads — they run in separate memory spaces and don't block the UI. For heavy compute like ML inference, they're non-negotiable.

import 'dart:isolate';

class IsolatedInference {
 static Future<String> generate(String modelPath, String prompt) async {
 return await Isolate.run(() {
 // This runs on a separate isolate — UI stays responsive
 final bridge = LlamaBridge();
 bridge.loadModel(modelPath);
 final result = bridge.generate(prompt, maxTokens: 128);
 return result;
 });
 }
}

// Usage in a widget
Future<void> onSendMessage(String userMessage) async {
 setState(() => _isGenerating = true);

 final reply = await IsolatedInference.generate(
 _modelPath,
 'Suggest a brief reply to: $userMessage',
 );

 setState(() {
 _suggestions = _parseSuggestions(reply);
 _isGenerating = false;
 });
}

Isolate Gotcha: Model Reload

Because isolates have separate memory, you can't share a loaded model between the main isolate and a worker isolate. Each Isolate.run() call loads the model fresh. For frequent inference, use a long-lived isolate with ReceivePort/SendPort instead, so the model stays loaded between calls. The initial model load takes 1-3 seconds depending on size — you don't want that on every request.

For simpler cases, Flutter's compute() function works too — it's a wrapper around Isolate.run(). But for anything where you need to keep the model warm between requests, set up a persistent isolate. The state management patterns from my state management comparison guide apply here — you need a clean way to communicate between isolates.

Model Quantization — Making 7B Parameters Fit on a Phone

Quantization is the single most important concept for on-device AI. Without it, running language models on mobile hardware isn't realistic. A 7B parameter model in FP16 (half precision) takes up ~14GB. Phones don't have that kind of RAM to spare.

INT4 vs INT8 Tradeoffs

There are two quantization levels I've used in production:

The naming convention in GGUF files looks like model-7b-q4_k_m.gguf. The q4_k_m means 4-bit quantization with K-quant method, medium quality. There's also q4_k_s (small/faster) and q5_k_m (5-bit, better quality but larger).

Real Numbers from My Testing

I tested a Llama 3.2 3B model (Q4_K_M quantization) across several devices last month. Here's what I measured — these are real numbers, not marketing claims:

The 4GB RAM Cutoff

Based on my testing, devices with 4GB RAM or less can't reliably run even quantized 3B models. The OS itself uses 2-3GB, leaving barely 1GB for your app. For these devices, stick to TFLite classification models (under 50MB) or fall back to cloud APIs. I cover handling offline scenarios and fallbacks in a separate post.

A 3B parameter model at Q4_K_M generates decent smart replies and short summaries. It won't write essays or handle multi-step reasoning, but for the feature I built — suggesting three short replies to a message — it's more than adequate. The quality difference between a 3B local model and GPT-3.5 for short replies? Honest answer: users couldn't tell the difference in our A/B test.

Hardware Acceleration on iOS and Android

Running models on the CPU works, but both major mobile platforms offer hardware acceleration that can double or triple your inference speed.

On iOS, CoreML and Metal give you access to the Neural Engine and GPU. Apple's A-series and M-series chips have dedicated ML accelerators that demolish raw CPU performance. llama.cpp supports Metal out of the box — you just enable it at compile time with -DGGML_METAL=ON. That's why the iPhone 15 Pro numbers above are so good.

On Android, NNAPI (Neural Networks API) provides access to dedicated AI accelerators like Qualcomm's Hexagon DSP or Samsung's NPU. TFLite supports NNAPI through a delegate:

final interpreter = await Interpreter.fromAsset(
 'models/text_classifier.tflite',
 options: InterpreterOptions()
 ..addDelegate(NnApiDelegate()) // Use hardware accelerator
 ..threads = 2,
);

A word of caution though — NNAPI support varies wildly across Android devices. Some manufacturers implement it well, others have buggy or partial implementations. I always wrap NNAPI usage in a try/catch and fall back to CPU if it fails. The security implications of running models locally are generally positive (data stays on device), but you need to handle edge cases gracefully.

Cloud API vs On-Device — When to Pick Which

After shipping both cloud-based and on-device AI features, here's the decision framework I use now:

Go on-device when:

Go cloud when:

The Hybrid Approach

The best results I've gotten are from combining both. My e-commerce app uses local TFLite for product image classification (fast, offline-capable) and cloud APIs for generating product descriptions (needs GPT-4 quality). Think of on-device AI as your fast path and cloud as your power path. Users get quick responses for common tasks and richer responses for complex ones.

Building an Offline Chat Feature (Real Project Walkthrough)

Let me walk through the actual feature I built — the one that started all of this. A messaging app where users can get AI-suggested replies even when they're offline. I'll skip the full UI code (that's a separate post in itself) and focus on the AI plumbing.

Model Loading and Warm-Up

The model loads when the user opens the chat screen. I do a warm-up inference with a dummy prompt to pre-fill internal caches — the first real inference is then much faster:

class ChatAiService {
 late final SendPort _workerPort;
 bool _isReady = false;

 Future<void> initialize(String modelPath) async {
 final receivePort = ReceivePort();

 await Isolate.spawn(
 _workerEntryPoint,
 _WorkerConfig(sendPort: receivePort.sendPort, modelPath: modelPath),
 );

 // Get the worker's send port for future communication
 _workerPort = await receivePort.first as SendPort;

 // Warm-up inference — primes internal caches
 await _sendCommand('generate', 'Hello');
 _isReady = true;
 }

 Future<List<String>> suggestReplies(String lastMessage) async {
 if (!_isReady) return [];

 final prompt = '''Suggest 3 short, natural replies to this message.
Each reply should be under 15 words. Return only the replies, one per line.

Message: $lastMessage''';

 final result = await _sendCommand('generate', prompt);
 return result.split('\n').where((line) => line.trim().isNotEmpty).toList();
 }
}

Conversation Manager

The conversation manager tracks message history and formats prompts. I keep a sliding window of the last 10 messages for context — more than that and the prompt gets too long for a 3B model to handle well:

class ConversationManager {
 final List<ChatMessage> _history = [];
 final int _maxContext = 10;

 void addMessage(ChatMessage message) {
 _history.add(message);
 if (_history.length > _maxContext) {
 _history.removeAt(0);
 }
 }

 String buildPrompt() {
 final buffer = StringBuffer();
 for (final msg in _history) {
 final role = msg.isUser ? 'User' : 'Assistant';
 buffer.writeln('$role: ${msg.text}');
 }
 return buffer.toString();
 }
}

Wiring It Into the UI

The UI shows suggestion chips above the keyboard. When the user taps one, it sends that reply. I use a ValueNotifier to keep things simple — no need for Riverpod or Bloc for a feature this contained (though if you're curious about the tradeoffs, check my Bloc vs Riverpod comparison):

class SmartReplyWidget extends StatelessWidget {
 final ValueNotifier<List<String>> suggestions;
 final Function(String) onTap;

 const SmartReplyWidget({
 required this.suggestions,
 required this.onTap,
 super.key,
 });

 @override
 Widget build(BuildContext context) {
 return ValueListenableBuilder<List<String>>(
 valueListenable: suggestions,
 builder: (context, replies, _) {
 if (replies.isEmpty) return const SizedBox.shrink();
 return Wrap(
 spacing: 8,
 children: replies.map((reply) => ActionChip(
 label: Text(reply),
 onPressed: () => onTap(reply),
 )).toList(),
 );
 },
 );
 }
}

The whole flow: user receives a message → ConversationManager updates history → ChatAiService.suggestReplies() runs inference on the worker isolate → UI shows three suggestion chips → user taps one → message sends. Total time from message received to suggestions displayed: 250-400ms on a Pixel 8.

I added push notifications that pre-warm the AI model when a new message arrives, so by the time the user opens the chat, the model is already loaded and ready. That shaved another 1.5 seconds off the perceived latency.

What I'd Do Differently Next Time

After three production apps with on-device AI, here's what I've learned:

Start with the smallest model that works. I wasted two weeks trying to get a 7B model running on Android before realizing a 3B model produced nearly identical results for my use case. Always benchmark the smallest option first.

Test on actual budget devices. Every tutorial shows benchmarks on flagship phones. In Pakistan, most of my users have phones with 4-6GB RAM. Buy a few budget phones for testing — it'll save you from a rude awakening at launch. My testing strategy guide covers device matrix planning in more detail.

Ship the model separately from the app. Baking a 300MB model into your APK means a 300MB download. Instead, download the model on first launch and store it in the app's documents directory. Users on slow connections will thank you.

Always have a fallback. Not every device can run the model. Not every model load succeeds. Design your AI features as progressive enhancements — the app should work without them. Check my clean architecture guide for patterns that make fallback logic clean and testable.

On-device AI in Flutter isn't just a cool tech demo anymore — it's a practical approach that solves real problems around latency, cost, and offline functionality. The tooling isn't as polished as cloud APIs yet (no nice SDK, more manual setup), but the results are worth the effort. If you're building a Flutter app with AI features, at least evaluate whether some of those features can run locally. You might be surprised how much you can do without ever hitting the network.

Curious about other ways to push your Flutter apps further? My guides on essential Flutter packages and Supabase vs Firebase cover more of the tools I use daily for production apps.

Related Articles

Frequently Asked Questions

Can Flutter actually run AI models on a phone?

Yes — I've shipped three Flutter apps with on-device AI features. For classification and detection tasks, the tflite_flutter package wraps TensorFlow Lite and handles models under 50MB with near-instant inference. For text generation, you bridge llama.cpp to Dart via FFI and run quantized GGUF models. My three production apps use a mix of both approaches. The key is picking the right model size for your target hardware — a 3B parameter model in Q4 quantization works well on phones with 6GB+ RAM.

How big can an AI model be for on-device Flutter inference?

From my production testing, keep models under 500MB for comfortable RAM usage on modern phones. INT4 quantized 3B-parameter models sit around 200-300MB and generate text at 8-12 tokens per second on flagship Android devices. For classification with TFLite, models under 50MB work great and run inference in under 100ms. I tested a 7B model (Q4_K_M, ~4GB) and it only worked on the iPhone 15 Pro and Pixel 8 Pro — too much for midrange phones. Stick with 3B or smaller for broad device compatibility.

Is on-device AI better than cloud APIs for Flutter apps?

It depends on the task. I use on-device models for anything that needs offline support or fast response times — smart replies, text classification, sentiment analysis. Cloud APIs like OpenAI and Gemini are better for complex reasoning that needs 70B+ parameter models. About 70% of AI features in my apps worked fine with local models. The decision comes down to: do you need it fast, cheap, and offline? Go local. Do you need it smart and don't mind latency? Go cloud. I use both in the same app for different features.

Which Flutter packages work best for local AI integration?

tflite_flutter (v0.11.x on pub.dev) is my top recommendation for TensorFlow Lite models — image classification, text embedding, object detection. For running LLMs on-device, there's no official Flutter package yet, so you need Dart FFI bindings to llama.cpp or use flutter_rust_bridge if your team writes Rust. Google ML Kit also works well for common tasks like face detection and barcode scanning. I walk through the FFI setup step by step in this guide — it took me a weekend to get right the first time, but the pattern is reusable.

Does running AI models drain battery on mobile devices?

Single inference calls barely register on battery — generating one smart reply takes less power than loading a typical webpage. Continuous inference (like real-time translation as someone types) will drain battery faster. In my testing, a 30-minute chat session with local LLM inference during active messaging used about 3-4% battery on a Pixel 8. Most users won't even notice. The bigger concern is thermal throttling — run inference constantly and the phone heats up, which forces the CPU to slow down. I run inference only when triggered by user actions, not continuously.

How do I handle devices that can't run AI models?

Feature detection first — check available RAM and whether the model loads successfully before offering AI features. In my apps, I wrap model loading in a try/catch. If it fails (low memory, unsupported architecture, corrupted model file), the app falls back to a cloud API call for the same feature. This way the app works everywhere, and local inference is a progressive enhancement rather than a hard requirement. Devices with 4GB RAM or less almost always fail to load 3B+ models, so I skip the attempt entirely on those and go straight to the cloud fallback.

Share this article:

Have an App Idea?

Let our team turn your vision into reality with Flutter.