●NANOLITE — Nano Banana 2 Lite is here: Google's fastest and most cost-efficient Gemini Image model, made for running lightweight image generation cheaply●OMNIFLASH — Gemini Omni Flash is in public preview, a natively multimodal model that lets enterprises and developers build custom, dynamic video workflows●AGENTS — Managed Agents expand with background: true for async server-side runs and polling, remote MCP server integration, and refreshing credentials across interactions●MEMORY — The Memory Bank IngestEvents API is generally available, decoupling event ingestion from memory generation so you can stream content continuously●THROUGHPUT — Provisioned Throughput now lets you submit up to seven pending orders for the same model and region●DEPRECATE — Image generation models shut down on August 17, and the Grok 4.1 family on the Gemini Enterprise Agent Platform on August 20●NANOLITE — Nano Banana 2 Lite is here: Google's fastest and most cost-efficient Gemini Image model, made for running lightweight image generation cheaply●OMNIFLASH — Gemini Omni Flash is in public preview, a natively multimodal model that lets enterprises and developers build custom, dynamic video workflows●AGENTS — Managed Agents expand with background: true for async server-side runs and polling, remote MCP server integration, and refreshing credentials across interactions●MEMORY — The Memory Bank IngestEvents API is generally available, decoupling event ingestion from memory generation so you can stream content continuously●THROUGHPUT — Provisioned Throughput now lets you submit up to seven pending orders for the same model and region●DEPRECATE — Image generation models shut down on August 17, and the Grok 4.1 family on the Gemini Enterprise Agent Platform on August 20
Building an AI Chat App with Expo and Gemini API: From First Commit to App Store Approval
An implementation record of shipping an Expo + Gemini API chat app through both store reviews — measured streaming latency, the chunk-boundary JSON bug, image payload limits, cost ceilings, and the pre-release checklist I run.
The first time Gemini's response arrived one character at a time in my Expo chat view, I stopped and just watched it. Getting there had taken three days.
When I set out to integrate the Gemini API into a React Native app, I expected the process to be straightforward. It wasn't.
The streaming implementation alone took me the better part of two days to get right. The issue wasn't the Gemini API itself — the documentation is solid. The problem is that React Native's JavaScript environment behaves differently from a browser or Node.js, and the examples in the official docs are written with those environments in mind.
This article is a practical implementation record. I'll cover the parts that actually caused friction: streaming in Hermes, managing chat history without blowing up your token budget, handling image input, designing a freemium model that makes economic sense, and navigating the AI-specific requirements that App Store and Google Play reviewers will flag.
Why Expo, and What You Need to Know About Its Limits
For indie developers shipping to both iOS and Android, Expo's managed workflow is the right default choice. You avoid dealing with Xcode configuration drift, native module linking headaches, and platform-specific build systems. EAS Build handles the pipeline.
Where Expo's managed workflow gets constrained is around native APIs. For Gemini API integration, this matters in two places:
Camera and image library access: handled through expo-image-picker — no native code required
Audio input: handled through expo-av, though Live API integration requires more setup
The JavaScript engine is Hermes, which has its own quirks around async iterators and stream APIs. This is the root cause of the streaming problems I'll describe next.
I'm using Expo SDK 53 with @google/genai SDK 1.x. If you're on an older SDK version, upgrade first — streaming behavior changed significantly between SDK 51 and 52.
API key management matters from day one. Hardcoding your key in source code is one GitHub push away from being scraped and abused. In React Native, there are two sensible approaches:
Server-side proxy: Your app calls your own backend, which calls Gemini. The API key never leaves your server. Best for production.
expo-secure-store: Encrypts the key at rest on the device. Acceptable for early testing; still exposes the key if someone reverse-engineers your app.
For development, use environment variables with the EXPO_PUBLIC_ prefix:
// lib/gemini.tsimport { GoogleGenAI } from "@google/genai";const API_KEY = process.env.EXPO_PUBLIC_GEMINI_API_KEY ?? "";if (!API_KEY) { throw new Error( "GEMINI_API_KEY is not set. Check your .env.local file." );}export const genAI = new GoogleGenAI({ apiKey: API_KEY });
.env.local:
EXPO_PUBLIC_GEMINI_API_KEY=YOUR_GEMINI_API_KEY
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Measured latency across three streaming approaches, and where perception diverges from total time
✦A buffering parser that survives JSON split across chunk boundaries
✦An eight-item pre-release checklist covering review, cost, and failure modes
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Streaming Responses: Why the Obvious Approach Doesn't Work
This is where most developers hit their first wall.
In a browser or Node.js environment, you can read a streaming response like this:
// ❌ Works in browsers, often fails in React Nativeconst response = await fetch(url, { method: "POST", body: JSON.stringify(body) });const reader = response.body!.getReader(); // body may be null in React Native
In React Native on Hermes, response.body is frequently null, or the ReadableStream API behaves inconsistently depending on the SDK version. Don't fight it. Instead, use the @google/genai SDK's generateContentStream, which handles the low-level stream processing internally and exposes an async iterable that works reliably on Hermes.
The for await...of stream pattern works on Hermes in Expo SDK 52+. On older SDKs, async iterators may not be fully supported. If you're hitting issues, this is the first thing to check.
Smoothing Out the UI
Calling setState on every chunk can cause visible jitter — you'll see text flickering in at slightly uneven intervals. A simple 16ms buffer dramatically improves the perceived smoothness:
Managing Chat History Without Breaking Your Budget
The official SDK examples pass the full conversation history on every request. In a long session, this gets expensive fast. Gemini 2.5 Flash has very low input token costs, but a heavy user accumulating hundreds of messages will still add up — especially if you're running the freemium model I'll describe later.
Use a sliding window: keep only the last N message pairs in the history you send to the API.
For users who want persistent cross-session memory, Firebase Firestore is the next step up. But for the early release phase, AsyncStorage works well enough and has zero infrastructure cost.
One thing to watch: the trimming happens on the history you send to the API, not on what you display in the UI. Users should see the full conversation in the app even if the model doesn't have that full context. Be explicit in the UI if the model might not remember something from earlier in the chat.
Multimodal Input: Image Understanding in Expo
Gemini's vision capabilities are one of its strongest features. Here's how to send an image from the device's photo library to the API:
// components/ImageAnalyzer.tsximport * as ImagePicker from "expo-image-picker";export async function analyzeImage( prompt: string = "Describe what you see in this image."): Promise<string> { const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ImagePicker.MediaTypeOptions.Images, quality: 0.7, // Compress to reduce payload size base64: true, // ⚠️ Required — null without this flag }); if (result.canceled || !result.assets?.[0]) { throw new Error("No image selected"); } const asset = result.assets[0]; if (!asset.base64) { throw new Error("base64 encoding failed — ensure base64: true is set"); } const response = await genAI.models.generateContent({ model: "gemini-2.5-flash", contents: [ { parts: [ { text: prompt }, { inlineData: { mimeType: asset.mimeType ?? "image/jpeg", data: asset.base64, }, }, ], }, ], }); return response.text ?? "";}
Common mistake: forgetting base64: true in the picker options. Without it, asset.base64 is null and you'll get a runtime error that doesn't point directly at the cause. It took me longer than I'd like to admit to track this down the first time.
For large images (especially from high-resolution cameras), inlineData with a raw base64 payload can hit timeout limits. Use expo-image-manipulator to resize images above 1MB before sending:
import * as ImageManipulator from "expo-image-manipulator";const resized = await ImageManipulator.manipulateAsync( asset.uri, [{ resize: { width: 1024 } }], // Maintain aspect ratio { compress: 0.7, format: ImageManipulator.SaveFormat.JPEG, base64: true });// Use resized.base64 instead of asset.base64
Freemium Design and Cost Control
Running an AI app as a solo developer means you're personally responsible for the API bill. I've designed the economics around what worked with my wallpaper and wellness apps: give users enough value to get hooked, then offer a clear upgrade path.
Free tier: 10 requests per day using Gemini 2.5 Flash. After the limit, prompt the user to watch an ad (AdMob) for 5 additional requests, or subscribe to premium.
This is client-side enforcement only — a determined user can bypass it by clearing app data. For early-stage apps, that's an acceptable tradeoff. When you have a user base worth protecting, move the limit enforcement to your server.
Model routing by tier is another lever for cost control:
const MODEL_CONFIG = { free: "gemini-2.5-flash", premium: "gemini-2.5-pro", image: "gemini-2.5-flash", // Vision quality on Flash is sufficient} as const;export function selectModel(isPremium: boolean, hasImage: boolean): string { if (hasImage) return MODEL_CONFIG.image; return isPremium ? MODEL_CONFIG.premium : MODEL_CONFIG.free;}
App Store and Google Play Review: What AI Apps Need to Get Right
This is the part most tutorials skip, and it's where submissions get rejected.
Apple App Store
Apple's guidelines require that your app take responsibility for AI-generated content. In practice, this means:
Privacy policy: Explicitly state that user input is sent to Google's Gemini API for processing. "Your messages are processed by Google's Gemini API" is the minimum. Include a link to Google's privacy policy alongside your own.
App Store Connect — Privacy practices: In the data collection section, check "Data linked to you → Other data types" if you're storing any chat history server-side. If you're purely local (AsyncStorage only), this simplifies the disclosure.
Age rating: AI chat apps typically can't use the 4+ rating. You'll need to select 17+ or provide evidence that your content filtering is robust enough for younger audiences. Gemini API's default safety settings are fairly conservative, but if you've customized them, document your approach.
In-app AI disclosure: Apple increasingly requires apps to make it visible to users that they're interacting with an AI — not a human. A simple "Powered by Gemini AI" label in the chat header is usually sufficient.
Google Play
Google Play's policies around AI-generated content are more explicitly documented than Apple's. Key requirements:
Deceptive behavior policy: Your app cannot misrepresent the AI as a human. Similar to Apple — surface the AI nature clearly in your UI.
AI content policy: Apps that generate content must have a mechanism to prevent the creation of content that violates Play's policies (hate speech, harassment, etc.). Gemini's safety filters satisfy this requirement if left at their default settings. If you've relaxed them, you'll need to describe your alternative safeguards in your Play Console declaration.
Data safety section: Declare that user data is shared with Google (via Gemini API). This appears in your Play Store listing.
Rate Limit Handling (Both Platforms)
Reviewers test apps on fresh installs without any cached state. If your app crashes or shows an unhandled error when the API is slow or unavailable, you'll get rejected. Implement retry with exponential backoff:
Also: make sure your app handles the offline case gracefully. A clear "No internet connection" state is required, and reviewers will test it by turning on airplane mode.
Measured Latency Across Three Streaming Approaches
People often ask whether streaming is worth the added complexity. Here are the numbers from my own device.
Tested on an iPhone 14 (iOS 18.4) over Wi-Fi, same prompt (summarize a 400-character Japanese passage), 30 runs per approach, averaged. Model was gemini-2.5-flash with maxOutputTokens pinned to 512.
Approach
Time to first character
Time to full response
Implementation cost
Single request (generateContent)
— (blank until 4,820 ms)
4,820 ms
Low
streamGenerateContent + manual chunk parsing
640 ms
5,110 ms
Medium
expo/fetch streaming API
610 ms
5,040 ms
Medium
Notice that streaming is 200–300 ms slower to finish. On total processing time, streaming loses.
I still choose streaming every time. A blank screen for 4.8 seconds and a response that starts moving at 0.6 seconds are not the same experience. In the non-streaming build I put on TestFlight, users visibly navigated away mid-wait.
Optimizing a number and holding someone's attention are two different axes.
JSON Splits Across Chunk Boundaries
This is where I lost the most time. Chunks returned by streamGenerateContent can end in the middle of a JSON object. Passing each chunk straight to JSON.parse fails roughly once every few dozen responses.
The failure rate is low enough that you will not catch it in development. You find it in crash logs after release.
// ❌ Before: assumes every chunk is a complete JSON objectfor await (const chunk of stream) { const json = JSON.parse(chunk); // throws intermittently appendText(json.candidates[0].content.parts[0].text);}
Buffer the stream, and only emit an object once its braces balance.
// ✅ After: keep the incomplete fragment in the bufferlet buffer = "";function drainCompleteObjects(chunk: string): string[] { buffer += chunk; const results: string[] = []; let depth = 0; let start = -1; let inString = false; let escaped = false; for (let i = 0; i < buffer.length; i++) { const c = buffer[i]; if (escaped) { escaped = false; continue; } if (c === "\\") { escaped = true; continue; } if (c === '"') { inString = !inString; continue; } if (inString) continue; if (c === "{") { if (depth === 0) start = i; depth++; } else if (c === "}") { depth--; if (depth === 0 && start >= 0) { results.push(buffer.slice(start, i + 1)); start = -1; } } } buffer = start >= 0 ? buffer.slice(start) : ""; return results;}
Tracking depth matters because a model response containing code will include { inside the text field. Skipping string literals matters for the same reason. Drop either guard and the parser breaks the moment someone asks for a code snippet.
What the Docs Don't Tell You
Documentation explains how things work. It rarely explains how they break. These are the observations I've collected while running the app.
Input tokens grow faster than the conversation does. If you resend the full history each turn, the tenth request carries roughly ten times the tokens of the first. Linear per turn, quadratic in total. Introduce summarization or context caching earlier than feels necessary — the caching side is covered in cutting costs with Gemini API context caching, and history design itself in long-term memory and session persistence patterns.
Retrying 429s amplifies spend. Exponential backoff is the correct response, but retried requests are still billed. If rate limiting has become routine, thickening the retry layer only grows the invoice quietly. Reduce concurrency first.
Shrink images before you send them. Once the inlineData payload crossed roughly 2 MB, timeouts on cellular connections rose sharply. Downscaling to a 1,024 px long edge at JPEG quality 0.7 barely moved accuracy on UI screenshot checks. I wrote up the reasoning in why you should resize images before uploading.
Don't leave reasoning depth at its default. On models that expose a thinking budget, the default is often more than a chat interface needs. Capping it visibly lowers the bill — the implementation is in controlling thinking_budget to keep costs down.
I prefer to start biased toward Flash and move specific paths up to Pro once I can point at where quality falls short. Starting on Pro and cutting back leaves you with less evidence.
The Checklist I Run Before Submitting
No API key embedded in the client (EXPO_PUBLIC_ variables ship inside the bundle)
Launch in airplane mode — an error surfaces, nothing crashes
Navigating away mid-response aborts the stream (AbortController actually wired up)
Pasting a 5,000-character passage doesn't freeze the UI
Hitting the daily free limit shows the right message and the right next step
Image input without base64: true takes a handled branch, not an exception
Privacy policy states that input is processed on Google's servers
Token usage is being logged from day one
Items 3 and 6 I found on a physical device, not in review.
Shipping and What Comes Next
The pattern I keep returning to: ship the simplest version that reveals whether users actually want the core experience, then instrument everything so you can make data-driven decisions about what to build next.
For an AI chat app specifically, the metrics that matter most in the first few weeks are session length, return rate (do users come back the next day?), and where they hit the usage limit. Those three numbers will tell you more about product-market fit than anything else.
The Gemini API makes it technically feasible for a single developer to build something that genuinely feels intelligent. Whether that intelligence solves a real problem for real people — that part is still entirely on you.
If you're working through similar implementation challenges, I hope this saves you the hours I spent on the streaming issues alone.
Share
Thank You for Reading
Gemini Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.