●CHAT — From August 26, Google Chat becomes a Gemini hub for searching, drafting, catching up on threads, and managing tasks and events with full Workspace context. Three days out●ANDROID — Gemini replaces Google Assistant on Android from September 4, twelve days from now. Worth checking any voice shortcuts you built on Assistant before the switch●SCALE — The Gemini app crossed one billion monthly users on August 11●ROBOTICS — gemini-robotics-er-1.6-preview shuts down on August 31, eight days out. The ER 2 preview models succeed it, adding spatial reasoning, multi-step tool orchestration, and multi-robot coordination●FLASH — Gemini 3.7 Flash went GA on August 13 and now powers Gemini Spark for AI Pro and Ultra subscribers in 160-plus countries. Introductory pricing runs through December 31●CLASSROOM — Gemini in Classroom opened to students of all ages on August 10, with flashcards, practice quizzes, study guides, and guided prompts●CHAT — From August 26, Google Chat becomes a Gemini hub for searching, drafting, catching up on threads, and managing tasks and events with full Workspace context. Three days out●ANDROID — Gemini replaces Google Assistant on Android from September 4, twelve days from now. Worth checking any voice shortcuts you built on Assistant before the switch●SCALE — The Gemini app crossed one billion monthly users on August 11●ROBOTICS — gemini-robotics-er-1.6-preview shuts down on August 31, eight days out. The ER 2 preview models succeed it, adding spatial reasoning, multi-step tool orchestration, and multi-robot coordination●FLASH — Gemini 3.7 Flash went GA on August 13 and now powers Gemini Spark for AI Pro and Ultra subscribers in 160-plus countries. Introductory pricing runs through December 31●CLASSROOM — Gemini in Classroom opened to students of all ages on August 10, with flashcards, practice quizzes, study guides, and guided prompts
Moving app AI work from runtime calls to a pre-ship batch pass
Where you put a Gemini call decides whether your request count scales with users or with assets. Here is the decision rule I used to move classification into a pre-ship batch pass, plus a resumable implementation.
Adding artwork to the ukiyo-e wallpaper app starts the same way every time: opening the preview folder and going through the images one by one, deciding which of the 30 categories each belongs to.
Once Gemini took over that sorting, I assumed without really thinking about it that the call would live inside the app. Tap the category tab, classify on the spot. On a whiteboard it looked perfectly reasonable.
I only ran the estimate right before implementation. Same processing, same model — but moving where the call lived changed the monthly request count by three orders of magnitude.
Here is the conclusion up front: if the input does not depend on the user and the set of inputs can be enumerated ahead of time, finishing the calls before you ship is the better trade for an indie developer. I kept exactly one feature at runtime, and only because its input cannot be enumerated.
What actually drives your request count
I had quietly treated "adding AI to the app" and "calling the API at runtime" as the same sentence. That is where the trap was.
The same classification behaves very differently depending on where it sits:
At runtime, the count scales with users × sessions. It grows as the app grows
Before shipping, the count scales with asset count. It does not move when users double
Wallpaper categories look the same to everyone who opens the app. I was recomputing one shared answer once per person. Once that registered, my question stopped being "which is faster" and became "what is this proportional to."
Putting both options into the same formula
The estimate takes about twenty lines. Writing it before you implement is what keeps the bill from surprising you later.
# Compare monthly call counts for the same feature at runtime vs. pre-ship.# Swap in your own numbers before running.def runtime_calls(mau, sessions_per_user, calls_per_session): """Runtime placement. Grows in proportion to your user base.""" return mau * sessions_per_user * calls_per_sessiondef preship_calls(new_assets, passes_per_asset, prompt_revisions): """Pre-ship placement. Proportional to assets. prompt_revisions counts full re-runs after you edit the prompt.""" return new_assets * passes_per_asset * (1 + prompt_revisions)rows = []for mau in (500, 5_000, 50_000): rt = runtime_calls(mau, sessions_per_user=6, calls_per_session=1) ps = preship_calls(new_assets=120, passes_per_asset=1, prompt_revisions=2) rows.append((mau, rt, ps, rt / ps))print(f"{'MAU':>8} {'runtime/mo':>12} {'preship/mo':>12} {'ratio':>8}")for mau, rt, ps, ratio in rows: print(f"{mau:>8,} {rt:>12,} {ps:>12,} {ratio:>7.1f}x")print(f"\nbreak-even MAU = {360 / (6 * 1):.0f}")
Running it locally:
MAU runtime/mo preship/mo ratio 500 3,000 360 8.3x 5,000 30,000 360 83.3x 50,000 300,000 360 833.3xbreak-even MAU = 60
Sixty users. Past the point where you are handing builds to friends, runtime placement stops paying for itself on volume alone. Add 120 assets a month, rewrite the prompt twice and re-run everything, and you are still pinned at 360 calls.
As a ratio: 8.3x at 500 MAU, 83.3x at 5,000, 833.3x at 50,000. Only one side changes order of magnitude.
None of that surfaces until you are in production reading an invoice, and unwinding the design at that point costs you an app review cycle on top. The cheap way to avoid it is running those twenty lines before you write the feature.
The absolute numbers matter less than the shape: only one of these two is proportional to your user count. Per-call prices fall over time; the proportional side keeps climbing with growth regardless. If you want to record what each call actually costs you, I wrote up that side in recording production cost with the Gemini API usageMetadata field.
✦
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
✦You will be able to tell, with a short formula, whether each AI feature in your app scales with your user count or with your asset count
✦You will avoid the class of design mistake that only shows up as an unexpected bill months after launch, by spending a few minutes on the estimate first
✦You will be able to lift a resumable batch pass straight into your own project, one that converges to the same result even if it dies halfway
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.
What moves before shipping, and what genuinely cannot
Four questions settled every case for me.
Work
Inputs enumerable in advance?
Same answer for everyone?
Who waits on failure?
Placement
Asset category classification
Yes
Yes
Nobody
Pre-ship
Asset descriptions and alt text
Yes
Yes
Nobody
Pre-ship
Release notes in other languages
Yes
Yes
Nobody
Pre-ship
Interpreting a search query
No
No
The person on screen
Runtime
Screening user-submitted text
No
No
The person on screen
Runtime
The first column carries most of the weight. When the answer there is yes, the other three usually follow.
Write the pre-ship pass assuming it will die halfway
Moving work before the ship date means running hundreds of items in one go. Connections drop. You stop the script yourself to go do something else. Restarting from zero every time is not a workable habit.
I keyed the pass on a content hash so finished items are skipped. The same asset is billed once no matter how often you re-run.
"""Classify wallpaper assets before shipping.Interrupt it at any point; the next run picks up where it stopped."""import hashlibimport jsonimport pathlibfrom google import genaifrom google.genai import typesCLIENT = genai.Client(api_key="YOUR_API_KEY")MODEL = "gemini-2.5-flash"LEDGER = pathlib.Path("classified.jsonl") # append-only; this file is the deliverableCATEGORIES = ["landscape", "portrait", "flower", "wave", "night", "uncategorized"]SCHEMA = types.Schema( type=types.Type.OBJECT, required=["category", "confidence"], properties={ "category": types.Schema(type=types.Type.STRING, enum=CATEGORIES), "confidence": types.Schema(type=types.Type.NUMBER), },)def content_key(path: pathlib.Path) -> str: """Identity comes from bytes, not filenames, so a rename never bills twice.""" return hashlib.sha256(path.read_bytes()).hexdigest()[:16]def load_done() -> dict: if not LEDGER.exists(): return {} done = {} for line in LEDGER.read_text(encoding="utf-8").splitlines(): if not line.strip(): continue rec = json.loads(line) done[rec["key"]] = rec # later entries win if a key repeats return donedef classify(path: pathlib.Path) -> dict: res = CLIENT.models.generate_content( model=MODEL, contents=[ types.Part.from_bytes(data=path.read_bytes(), mime_type="image/jpeg"), "Assign this image to exactly one of the given categories. " "Choose uncategorized when you cannot decide.", ], config=types.GenerateContentConfig( response_mime_type="application/json", response_schema=SCHEMA, temperature=0, # a drifting result makes the ledger much less useful ), ) return json.loads(res.text)def main(src="_preview"): done = load_done() files = sorted(pathlib.Path(src).glob("*.jpg")) skipped = called = failed = 0 with LEDGER.open("a", encoding="utf-8") as fp: for path in files: key = content_key(path) if key in done: skipped += 1 continue try: result = classify(path) except Exception as exc: # one bad item must not stop the run failed += 1 print(f" fail {path.name}: {exc}") continue record = {"key": key, "file": path.name, **result} fp.write(json.dumps(record, ensure_ascii=False) + "\n") fp.flush() # survive a kill signal with work intact called += 1 print(f"total={len(files)} called={called} skipped={skipped} failed={failed}")if __name__ == "__main__": main()
The flush() on every line is deliberate. An earlier version buffered writes and lost several dozen classifications to a forced quit. Flushing per line makes the worst case predictable instead of surprising.
Once the work moves earlier, the API call disappears from the app entirely. What remains is reading a generated table.
// Ship the generated table with the app; at runtime we only read it.type Classified = { file: string; category: string; confidence: number };const KNOWN = new Set([ "landscape", "portrait", "flower", "wave", "night", "uncategorized",]);export function groupByCategory(rows: Classified[]): Map<string, string[]> { const grouped = new Map<string, string[]>(); for (const row of rows) { // Builds already in the field must survive categories added later, // because the pre-ship pass ships faster than app review does. const key = KNOWN.has(row.category) ? row.category : "uncategorized"; const bucket = grouped.get(key) ?? []; bucket.push(row.file); grouped.set(key, bucket); } return grouped;}
Those few lines that fold unknown categories into uncategorized are not optional. The batch pass can add a category whenever you like, but the shipped binary keeps whatever list passed review. Skip the guard and the day you add one category is the day older builds render an empty screen.
What surprised me after the move
I started this as a cost change. The parts that mattered turned out to be elsewhere.
The quality loop got much faster. While the call lives at runtime, every answer you already returned is gone. With the work sitting in a batch pass, editing the prompt and re-running replaces every classification at once. At three-digit call counts, "just redo all of it" is a real option.
Model choice stopped being constrained. Nobody is watching a spinner, so a few extra seconds cost nothing. Picking a cheaper, slower model became a free decision rather than a compromise.
Consistency turned out to be a feature. I had filed "the same image lands in different categories for different people" under acceptable variance. In practice, that is precisely the kind of inconsistency people write in about.
There is a real cost on the other side. New assets are not reflected the moment they land — classification is now tied to your release cadence. If you add assets daily, this design is not available to you. Mine arrive every few weeks, which is why it fits.
As a rule of thumb: if your asset set is finite and turns over more slowly than weekly, move the work before the ship date. If assets land daily, leave the call at runtime and put the ceilings from the next section in place first.
The one feature I kept at runtime
Search query interpretation stayed. The input is whatever someone types, so it cannot be enumerated.
I gave it three ceilings instead:
Try a dictionary first. If the query matches an existing category or tag, no API call happens. Many queries stop right there
Cap calls per device per day. Past the cap, the day falls back to substring search
Always degrade to the old search on failure. Not reaching the model must never mean not being able to search
With those in place, the runtime count tracks "queries that missed the dictionary" rather than "users × sessions." When you do decide something belongs at runtime, it is worth asking whether you can re-anchor it to a smaller quantity like that.
What to check next
Pick one AI feature in your own app and write down, in a single line, what its call count is proportional to. If you find yourself writing "users × sessions," that is the signal to go back and ask whether the input really differs per person.
Until I made this switch, "adding AI" and "calling at runtime" were the same thought in my head. If you are carrying the same assumption, I hope this saves you an estimate you would otherwise run too late.
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.