Gemma on Flutter

This is where the concepts become code. With a quantized Gemma model and the flutter_gemma plugin, running a real LLM entirely on the user's phone is a handful of Dart calls — load the model, open a chat session, stream tokens into your UI. The plugin hides the runtime; you write an app.

The previous posts assembled the pieces: a case for on-device AI, the edge constraints, a quantized model, and the runtime that executes it. Now we wire them together in Flutter with the flutter_gemma plugin, which wraps the on-device runtime (MediaPipe on mobile/web, LiteRT on desktop) behind a Dart API. This post walks the shape of a real integration — model loading, sessions, streaming, and the patterns that keep it within the constraints. (The plugin’s exact API surface evolves across versions, so treat the code as the shape of the integration and check the current package docs for precise signatures.)

The integration at a glance

Running Gemma in a Flutter app has four conceptual steps, each mapping to something from earlier posts:

1. Get the model onto the device   (a quantized .task file — the shipping problem, post 8)
2. Initialize the model            (load into memory via the runtime — the memory constraint)
3. Create a chat / session         (holds conversation state + KV cache)
4. Send a prompt, stream tokens    (the generation loop, streamed to the UI)

The plugin turns each into a few Dart calls. The art is less in the API and more in respecting the constraints around it — model size, memory, streaming UX — which the earlier posts prepared you for.

Adding the plugin and choosing an engine

You add flutter_gemma to your pubspec.yaml, and — because the runtime is platform-specific — the engine for your target platform. For a mobile app that’s the MediaPipe engine package; the plugin architecture lets you provide the engine appropriate to where the app runs (MediaPipe for mobile/web with .task/.bin models, LiteRT-LM for desktop with .litertlm models, as covered in the runtime post). The key setup decisions:

Loading the model

Getting the model into memory is the first real interaction, and it reflects the memory constraint directly — loading is a spike, and the file is large. Conceptually:

// Illustrative shape — check flutter_gemma docs for the current API.
final gemma = FlutterGemmaPlugin.instance;

// Point the plugin at the model file already on the device (bundled or downloaded).
await gemma.modelManager.setModelPath(localModelPath);

// Initialize the model with your inference settings.
final model = await gemma.createModel(
  modelType: ModelType.gemmaIt,   // an instruction-tuned Gemma variant
  maxTokens: 1024,                 // bound context to control KV-cache memory
  // preferredBackend: gpu,        // hint the accelerator; runtime falls back
);

Two things to notice, both straight from earlier posts. First, setModelPath assumes the model file is already on the device — how it got there (bundled in the app vs. downloaded on first run) is the shipping problem, deferred to the final post. Second, maxTokens bounds the context, which directly bounds the KV cache memory — a concrete place where the memory constraint becomes a line of code. Set it as low as your feature tolerates.

Chat sessions and streaming

With a model loaded, you create a chat session that holds conversation history (and thus the KV cache), then send messages and stream the response:

// Illustrative shape.
final chat = await model.createChat(
  temperature: 0.7,
  // tokenBuffer / topK etc. as supported
);

await chat.addQueryChunk(Message.text(text: userInput, isUser: true));

// Stream tokens as they generate — do NOT block on the whole response.
final buffer = StringBuffer();
await for (final token in chat.generateChatResponseAsync()) {
  buffer.write(token);
  setState(() => _reply = buffer.toString());  // update UI incrementally
}

Streaming is not optional on-device — it’s essential UX. From the edge-constraints post, on-device generation runs at tens of tokens per second, so waiting for a complete response would leave the user staring at a spinner. Streaming tokens into the UI as they arrive makes a modest generation speed feel responsive, because text appears immediately and continuously. Always consume the async token stream and render incrementally; never await the full string for anything user-facing.

Note also that the chat session accumulates history, which grows the KV cache with every turn (the memory constraint again). For long conversations you manage this exactly as the LLM serving series described — bound the history, or summarize older turns — so memory stays within budget.

Keeping it responsive and safe

A few patterns make an on-device Gemma feature production-quality rather than a demo:

From demo to feature

The gap between “it generated text on my phone” and “shippable feature” is exactly the constraints this series has been building around, now expressed in code:

With this, you have a functioning on-device Gemma feature in Flutter. But an LLM alone only knows what’s in its weights. To make it useful over the user’s own data — privately, on-device — you add retrieval, which is the next post: on-device RAG and memory, done entirely without a server.

Key takeaways

Further reading

Sources & References

Gemma model variants