●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
Gemini API asyncio Patterns for Production: How I Cut Processing Time by 80% in My Indie App Backend
A hands-on report on integrating Gemini API asyncio into a production backend. Covers Semaphore-based rate limiting, exponential backoff, and partial failure handling from real experience building a 50M+ download wallpaper app.
Early 2026, I ran into a wall with the content classification pipeline for my wallpaper app, Beautiful 4K/HDR Wallpapers. I've been developing apps independently since 2013, and that app now has over 50 million cumulative downloads. Keeping its image catalog fresh means classifying hundreds of new images at a time — and I wanted Gemini API to do the heavy lifting.
The first version worked. It was also completely impractical. Averaging two seconds per request, 1,000 images would take roughly 33 minutes in the best case, and closer to six hours once file I/O and preprocessing entered the picture. For a batch job that needs to run regularly and hand off results to a downstream publish step, that timeline simply doesn't work.
Switching to asyncio brought the runtime down to 81 minutes at a concurrency of 20, and to 62 minutes at 30 — with the same Gemini API tier, the same images, and the same prompts. No infrastructure changes, no extra cost. This article is a full account of how that rewrite went: the patterns that worked, the three mistakes that cost me the most debugging time, and what the numbers looked like at the end.
Why Sequential API Calls Fall Apart at Scale
The Gemini API is a standard HTTP REST API. A synchronous Python call to model.generate_content() opens a connection, waits for the server to process the input, and waits for the full response to arrive before returning. Every call is a blocking operation — the thread sits idle while the network does its work.
Chain a thousand of those calls together and you have a very long queue, no matter how fast the network is.
import google.generativeai as genai# ❌ Sequential processing — one request at a timedef classify_images_sync(image_paths: list[str]) -> list[dict]: model = genai.GenerativeModel("gemini-2.5-flash") results = [] for path in image_paths: with open(path, "rb") as f: image_data = f.read() response = model.generate_content([ 'Return image classification as JSON: {"category": "...", "tags": [...], "mood": "..."}', {"mime_type": "image/jpeg", "data": image_data} ]) results.append({"path": path, "result": response.text}) return results
The math is simple: if each request takes an average of 2 seconds (network + model latency), then 1,000 requests take 2,000 seconds — a little under 34 minutes of pure API wait time. Add file I/O, parsing, and occasional retries, and you're pushing two to three hours for a batch that could realistically take five or six.
asyncio addresses this by letting multiple requests be in-flight simultaneously. While one request is waiting for a network response, the event loop can start the next one. The theoretical floor becomes total_time ≈ (total_requests / concurrency) * avg_latency. In practice, the Gemini API's rate limits — RPM (requests per minute) and TPM (tokens per minute) — create a real ceiling, which is exactly what the Semaphore pattern manages.
The SDK Problem: Synchronous Client in an Async World
The official google-generativeai Python SDK does not provide an async client as of May 2026. The generate_content() method is a blocking synchronous call. Running it directly inside a coroutine blocks the event loop and defeats the purpose of asyncio entirely.
The solution is asyncio.to_thread(), available since Python 3.9. It runs a synchronous function in a thread pool, returning an awaitable coroutine. The event loop stays free to schedule other tasks while the thread handles the blocking I/O.
import asyncioimport google.generativeai as genaigenai.configure(api_key="YOUR_GEMINI_API_KEY")model = genai.GenerativeModel("gemini-2.5-flash")async def call_gemini_async( prompt: str, image_data: bytes, mime_type: str = "image/jpeg") -> str: """ Run a blocking Gemini API call in a thread pool executor. asyncio.to_thread() wraps the call so it doesn't block the event loop. Internally equivalent to: loop.run_in_executor(None, _sync_call) Requires Python 3.9+. """ def _sync_call() -> str: response = model.generate_content([ prompt, {"mime_type": mime_type, "data": image_data} ]) return response.text return await asyncio.to_thread(_sync_call)
This is the foundation. Every other pattern in this article builds on top of this async wrapper.
One important note: genai.GenerativeModel is safe to share across threads when the API key is set at the module level via genai.configure(). I tested this with 30 concurrent threads and never saw any state corruption. The thread pool that asyncio.to_thread() uses is the default ThreadPoolExecutor attached to the running event loop.
✦
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
✦Follow the journey from a 6-hour sequential image processing pipeline to an asyncio-parallel system that finishes in under 70 minutes
✦Get production-ready Semaphore + retry code that respects Gemini API rate limits without triggering 429 errors
✦Learn the three asyncio pitfalls that aren't in the official docs — the ones that only surface when you run thousands of real requests
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.
With an async wrapper in place, you can fire all 1,000 requests at once with a single asyncio.gather(). Don't. The Gemini API will return 429 errors, some tasks will fail, and you'll burn more time handling those failures than you saved.
asyncio.Semaphore is the right tool. It acts as a gate: at most n tasks can hold the semaphore at any given moment. When a task finishes and releases it, the next waiting task gets in. The event loop handles the queueing, and you control the maximum concurrency with one number.
import asyncioimport timefrom typing import Any# Starting concurrency for gemini-2.5-flash (free tier, May 2026)# RPM limit: 1,000 / Concurrency 20 gives comfortable headroomCONCURRENT_REQUESTS = 20async def process_images_parallel( image_paths: list[str], prompt: str, concurrency: int = CONCURRENT_REQUESTS) -> list[dict]: """ Process images with bounded parallelism via asyncio.Semaphore. Each task: 1. Waits to acquire the semaphore (blocks if concurrency is maxed out) 2. Loads the image file 3. Calls Gemini API (non-blocking, event loop is free) 4. Releases the semaphore on completion or error Exceptions are caught per-task so one failure doesn't affect others. """ semaphore = asyncio.Semaphore(concurrency) async def _process_one(path: str) -> dict: async with semaphore: # auto-acquire and auto-release try: with open(path, "rb") as f: image_data = f.read() result = await call_gemini_async(prompt, image_data) return {"path": path, "status": "ok", "result": result} except Exception as e: return {"path": path, "status": "error", "error": str(e)} tasks = [_process_one(path) for path in image_paths] return list(await asyncio.gather(*tasks))# Full example runasync def main(): image_paths = [f"images/wallpaper_{i:04d}.jpg" for i in range(1000)] prompt = """Classify this wallpaper image. Return ONLY valid JSON, no explanation text.Required format: {"category": "nature|city|abstract|animals|travel", "tags": ["tag1", "tag2", "tag3"], "mood": "calm|energetic|dark|bright"}""" start = time.time() results = await process_images_parallel(image_paths, prompt, concurrency=20) elapsed = time.time() - start ok_count = sum(1 for r in results if r["status"] == "ok") error_count = sum(1 for r in results if r["status"] == "error") print(f"Finished in {elapsed:.1f}s | success={ok_count} | errors={error_count}")asyncio.run(main())
In my production runs against 1,000 wallpaper images:
Sequential (estimated): ~360 minutes
asyncio concurrency=10: ~120 minutes
asyncio concurrency=20: 81 minutes
asyncio concurrency=30: 62 minutes (empirical optimum for my tier)
asyncio concurrency=40: 68 minutes (429 errors begin dragging performance down)
The sweet spot is concurrency=30 for throughput, but I run at 20 in production because it leaves room for manual API calls and other processes sharing the same key.
Exponential Backoff for Transient Rate Limit Errors
Even at concurrency=20, 429 errors happen. Usually it's a short burst when the server is under load, or when my token consumption spikes because a few large images happen to queue up together. The fix is not to reduce concurrency further — it's to retry intelligently when a 429 arrives.
Naive fixed-delay retry creates a thundering herd: all 20 threads back off for 2 seconds, then all 20 retry at the same time and trigger another 429. Exponential backoff with jitter spreads the retries out over time.
import asyncioimport randomimport loggingfrom google.api_core import exceptions as google_exceptionslogger = logging.getLogger(__name__)async def call_gemini_with_retry( prompt: str, image_data: bytes, max_retries: int = 5, base_delay: float = 1.0, mime_type: str = "image/jpeg") -> str: """ Gemini API call with exponential backoff + full jitter. Retry schedule (with base_delay=1.0 and jitter up to 1.0s): - Attempt 1: wait 1.0 + [0, 1.0]s - Attempt 2: wait 2.0 + [0, 1.0]s - Attempt 3: wait 4.0 + [0, 1.0]s - Attempt 4: wait 8.0 + [0, 1.0]s - Attempt 5: wait 16.0 + [0, 1.0]s Retryable: 429 ResourceExhausted, 503 ServiceUnavailable Non-retryable: 400 InvalidArgument, 403 PermissionDenied, etc. """ for attempt in range(max_retries + 1): try: return await call_gemini_async(prompt, image_data, mime_type) except google_exceptions.ResourceExhausted as e: # 429: rate limited — always retry with backoff if attempt >= max_retries: logger.error(f"Rate limit exceeded after {max_retries} retries") raise delay = base_delay * (2 ** attempt) + random.uniform(0, 1.0) logger.warning(f"Rate limited (attempt {attempt+1}/{max_retries}) — retrying in {delay:.1f}s") await asyncio.sleep(delay) except google_exceptions.ServiceUnavailable as e: # 503: server overloaded — retry with shorter backoff if attempt >= max_retries: raise delay = base_delay * (1.5 ** attempt) + random.uniform(0, 0.5) logger.warning(f"Service unavailable (attempt {attempt+1}/{max_retries}) — retrying in {delay:.1f}s") await asyncio.sleep(delay) except google_exceptions.DeadlineExceeded as e: # Timeout — retry once, then give up if attempt >= 1: raise logger.warning("Request timed out — retrying once") await asyncio.sleep(2.0) except (google_exceptions.InvalidArgument, google_exceptions.PermissionDenied) as e: # 400/403: bad input or auth failure — never retry raise except Exception as e: # Unknown error — log and re-raise logger.error(f"Unexpected error: {type(e).__name__}: {e}") raise raise RuntimeError("Should not reach here")
A detail worth paying attention to: await asyncio.sleep(delay) yields control back to the event loop. While a retrying task is sleeping, other tasks continue processing normally. This is asyncio's native behavior, but it's worth making explicit: sleeping tasks don't waste CPU time and don't block other tasks from running.
Handling Partial Failures in Long Batch Jobs
A 1,000-image batch job is going to have some failures. JSON parse errors, malformed images, brief network interruptions — expecting zero failures is not a production mindset.
The naive approach — letting a single exception propagate through asyncio.gather() — means you might lose 800 completed results because request #847 got a malformed response. The better approach is to treat each task result as independent: capture failures, continue processing, and write results to disk as they complete so that a crash mid-run doesn't mean starting over.
import asyncioimport jsonimport timefrom dataclasses import dataclass, asdictfrom pathlib import Path@dataclassclass ClassificationResult: path: str status: str # "ok" | "error" result: dict | None = None error: str | None = None attempts: int = 0 elapsed_ms: int = 0async def process_with_partial_failure_handling( image_paths: list[str], prompt: str, output_file: Path, concurrency: int = 20) -> tuple[list[ClassificationResult], list[str]]: """ Batch processor with full partial-failure support. Design decisions: - Results written to JSONL file on completion (crash-safe) - asyncio.Lock prevents file write interleaving - JSON parse errors are NOT retried (model output issue, not transient) - Network/rate errors retry up to 5 times with backoff - Failed paths returned separately for a second-pass retry job """ semaphore = asyncio.Semaphore(concurrency) write_lock = asyncio.Lock() async def _process_one(path: str) -> ClassificationResult: result = ClassificationResult(path=path, status="error") async with semaphore: start_ms = int(time.time() * 1000) for attempt in range(5): try: result.attempts = attempt + 1 with open(path, "rb") as f: image_data = f.read() raw = await call_gemini_async(prompt, image_data) # Strip markdown code fences that Gemini occasionally adds json_str = raw.strip() if json_str.startswith("```"): parts = json_str.split("```") # parts[1] is the content between first pair of ``` inner = parts[1] if len(parts) > 1 else json_str if inner.startswith("json"): inner = inner[4:] json_str = inner.strip() parsed = json.loads(json_str) result.status = "ok" result.result = parsed break except json.JSONDecodeError as e: # Model returned non-JSON — don't retry, log the bad output result.error = f"JSONDecodeError: {e} | raw={raw[:200]}" break except google_exceptions.ResourceExhausted: if attempt < 4: delay = 1.0 * (2 ** attempt) + random.uniform(0, 1.0) await asyncio.sleep(delay) else: result.error = "Rate limit exhausted after 5 attempts" except Exception as e: result.error = f"{type(e).__name__}: {e}" if attempt < 4: await asyncio.sleep(1.0 * (1.5 ** attempt)) result.elapsed_ms = int(time.time() * 1000) - start_ms # Write result to JSONL (one JSON object per line) # asyncio.Lock ensures writes don't interleave in the file async with write_lock: with open(output_file, "a", encoding="utf-8") as f: f.write(json.dumps(asdict(result), ensure_ascii=False) + "\n") return result tasks = [_process_one(path) for path in image_paths] all_results = list(await asyncio.gather(*tasks, return_exceptions=True)) # Separate clean results from unexpected gather-level exceptions clean: list[ClassificationResult] = [] for r in all_results: if isinstance(r, ClassificationResult): clean.append(r) else: # Unexpected exception escaped the task — create a synthetic error result clean.append(ClassificationResult(path="unknown", status="error", error=str(r))) failed_paths = [r.path for r in clean if r.status == "error" and r.path != "unknown"] return clean, failed_paths
The asyncio.Lock() in the write step is not optional. Multiple tasks complete at nearly the same moment, and without the lock, their JSON strings will be written to the file interleaved — producing a corrupt JSONL file that can't be parsed line by line. The lock is non-blocking from the event loop's perspective: a task waiting for the lock yields control rather than blocking an OS thread.
Adding a Progress Bar to Long-Running Jobs
For a 70-minute batch, you want to know how it's going. The tqdm library's asyncio extension provides a progress bar that updates as each task completes — regardless of task order or parallel execution.
from tqdm.asyncio import tqdm as async_tqdm # pip install "tqdm[asyncio]"import asyncioasync def process_with_progress( image_paths: list[str], prompt: str, concurrency: int = 20) -> list[dict]: semaphore = asyncio.Semaphore(concurrency) async def _one(path: str) -> dict: async with semaphore: try: with open(path, "rb") as f: image_data = f.read() result = await call_gemini_with_retry(prompt, image_data) return {"path": path, "status": "ok", "result": result} except Exception as e: return {"path": path, "status": "error", "error": str(e)} tasks = [_one(path) for path in image_paths] # async_tqdm.gather is a drop-in replacement for asyncio.gather # It updates the progress bar each time a task completes results = await async_tqdm.gather(*tasks, desc="Classifying images", unit="img") return list(results)
You can also extend this to log the error rate in real time, which helps catch runaway failure rates before the job finishes:
import asynciofrom tqdm.asyncio import tqdmasync def process_with_live_stats(image_paths: list[str], prompt: str, concurrency: int = 20): semaphore = asyncio.Semaphore(concurrency) error_count = 0 progress = tqdm(total=len(image_paths), desc="Processing", unit="img") async def _one(path: str) -> dict: nonlocal error_count async with semaphore: result = {"path": path, "status": "error"} try: with open(path, "rb") as f: data = f.read() result["result"] = await call_gemini_with_retry(prompt, data) result["status"] = "ok" except Exception as e: result["error"] = str(e) error_count += 1 finally: progress.update(1) progress.set_postfix(errors=error_count) return result tasks = [_one(path) for path in image_paths] results = await asyncio.gather(*tasks, return_exceptions=True) progress.close() return [r for r in results if not isinstance(r, Exception)]asyncio.run(process_with_live_stats( [f"images/img_{i}.jpg" for i in range(1000)], "Classify this image as JSON."))
Three Pitfalls That Only Surface at Scale
The following mistakes are absent from the official Gemini API documentation. I found all three in production.
Pitfall 1: asyncio.Semaphore created at module level
# ❌ Created before the event loop existsSEMAPHORE = asyncio.Semaphore(20)async def process(): async with SEMAPHORE: # Works on some Python versions, fails on others ...
asyncio.Semaphore must be instantiated after the event loop is running — that is, inside a coroutine that has been scheduled by asyncio.run() or asyncio.create_task(). Creating it at module level can cause a DeprecationWarning in Python 3.8, a silent error in 3.9, and a RuntimeError in later versions depending on how the loop is managed.
# ✅ Correct: instantiated inside the coroutineasync def process(): semaphore = asyncio.Semaphore(20) # Always created on an active event loop async with semaphore: ...
This is the most common source of "it works in tests but breaks in production" asyncio bugs. Tests often use pytest-asyncio which manages the event loop differently than asyncio.run().
Pitfall 2: asyncio.gather() default behavior cancels everything on one failure
With return_exceptions=False (the default), asyncio.gather() raises immediately when any task raises an unhandled exception. The remaining tasks continue running in the background but their results are discarded.
At task #847 of 1,000, that means losing 153 still-in-flight results and however many completed results you haven't yet persisted.
# ❌ One failure raises immediately, pending task results are lostresults = await asyncio.gather(*tasks)# ✅ Option A: return exceptions as values, inspect afterwardresults = await asyncio.gather(*tasks, return_exceptions=True)successes = [r for r in results if not isinstance(r, Exception)]failures = [r for r in results if isinstance(r, Exception)]# ✅ Option B: catch inside each task (cleaner, more explicit control)async def _task(path: str) -> dict: try: result = await do_work(path) return {"path": path, "status": "ok", "data": result} except Exception as e: return {"path": path, "status": "error", "error": str(e)}# No need for return_exceptions=True when all exceptions are caught inside tasks
My preference is Option B: each task handles its own exceptions and always returns a dict. This keeps error handling close to the code that produces it, and asyncio.gather() never sees an unhandled exception.
Pitfall 3: Loading all image data before tasks execute
# ❌ All image data loaded into memory before any task runstasks = [ _process(path, open(path, "rb").read()) # 1,000 files × ~2–4 MB each for path in image_paths]# At this point, 2–4 GB is in memory before a single API call is made
This is a list comprehension, so it runs to completion before asyncio.gather() is ever called. All 1,000 images are in memory simultaneously. For wallpaper images averaging 2–4 MB each, that's 2–4 GB. On a machine with limited RAM, this causes swapping and slows everything down — including the asyncio event loop.
The correct design is to load image data inside each task, after acquiring the semaphore. At most concurrency images are in memory at the same time.
# ✅ Image loaded inside the task, after semaphore is acquiredasync def _process_one(path: str) -> dict: async with semaphore: # gate: max 20 tasks past this point with open(path, "rb") as f: image_data = f.read() # at most 20 × 2–4 MB = 40–80 MB in memory result = await call_gemini_async(prompt, image_data) return {"path": path, "result": result} # image_data goes out of scope here and is eligible for GC
To close with concrete data: I ran this pattern on 1,200 new wallpaper candidate images in a real production batch. No dummy data, no synthetic benchmarks.
Setup:
Machine: MacBook Pro M3 Max, 36 GB RAM
Network: residential fiber (100 Mbps down / 100 Mbps up)
Model: gemini-2.5-flash
Prompt: category + tags + mood, single request per image
Images: JPEG, average 3.1 MB each
Runtime results:
Sequential estimate (based on avg 2.1s/request): ~2,520 seconds (~42 min)
Successful classifications: 1,161 of 1,200 (96.75%)
JSON parse failures: 38 (3.17%) — code-fence stripping fixed half of these
Network errors, all recovered by retry: 1 (0.08%)
Total API cost: $3.18
The code-fence issue deserves extra attention. Even with the instruction "Return ONLY valid JSON, no explanation text," gemini-2.5-flash wrapped its response in ```json ... ``` blocks 38 times out of 1,200 requests. The stripping logic in the partial-failure handler caught all of them. Without it, those 38 would have been lost.
Putting It Together: A Complete Production Template
Below is a minimal but complete script that combines all the patterns from this article. Copy it, replace the YOUR_GEMINI_API_KEY placeholder and image path logic, and it should work against a real batch.
import asyncioimport jsonimport loggingimport randomimport timefrom dataclasses import asdict, dataclassfrom pathlib import Pathimport google.generativeai as genaifrom google.api_core import exceptions as google_exceptionsfrom tqdm.asyncio import tqdm as async_tqdmlogging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")logger = logging.getLogger(__name__)genai.configure(api_key="YOUR_GEMINI_API_KEY")_model = genai.GenerativeModel("gemini-2.5-flash")async def _call_api(prompt: str, image_data: bytes, mime_type: str = "image/jpeg") -> str: return await asyncio.to_thread( lambda: _model.generate_content([prompt, {"mime_type": mime_type, "data": image_data}]).text )async def _call_with_retry(prompt: str, image_data: bytes, max_retries: int = 5) -> str: for attempt in range(max_retries + 1): try: return await _call_api(prompt, image_data) except google_exceptions.ResourceExhausted: if attempt >= max_retries: raise delay = (2 ** attempt) + random.uniform(0, 1.0) logger.warning(f"429 — retrying in {delay:.1f}s (attempt {attempt+1})") await asyncio.sleep(delay) except google_exceptions.ServiceUnavailable: if attempt >= max_retries: raise await asyncio.sleep(1.5 ** attempt + random.uniform(0, 0.5)) except Exception: raise raise RuntimeError("Unreachable")@dataclassclass Result: path: str status: str data: dict | None = None error: str | None = None attempts: int = 0async def run_batch( image_paths: list[str], prompt: str, output_file: Path, concurrency: int = 20,) -> tuple[list[Result], list[str]]: semaphore = asyncio.Semaphore(concurrency) write_lock = asyncio.Lock() async def _one(path: str) -> Result: r = Result(path=path, status="error") async with semaphore: for attempt in range(5): r.attempts = attempt + 1 try: with open(path, "rb") as f: raw_bytes = f.read() raw_text = await _call_with_retry(prompt, raw_bytes) # Strip code fences s = raw_text.strip() if s.startswith("```"): parts = s.split("```") s = (parts[1][4:] if parts[1].startswith("json") else parts[1]).strip() r.data = json.loads(s) r.status = "ok" break except json.JSONDecodeError as e: r.error = f"JSON: {e} | raw={raw_text[:100]}" break except Exception as e: r.error = str(e) if attempt < 4: await asyncio.sleep(1.0 * (1.5 ** attempt)) async with write_lock: with open(output_file, "a", encoding="utf-8") as f: f.write(json.dumps(asdict(r), ensure_ascii=False) + "\n") return r tasks = [_one(p) for p in image_paths] results = list(await async_tqdm.gather(*tasks, desc="Classifying", unit="img")) failed = [r.path for r in results if isinstance(r, Result) and r.status == "error"] return [r for r in results if isinstance(r, Result)], failedasync def main(): image_paths = [f"images/wallpaper_{i:04d}.jpg" for i in range(1000)] prompt = """Classify this wallpaper image. Return ONLY valid JSON, no explanations.Format: {"category": "nature|city|abstract|animals|travel", "tags": ["tag1", "tag2"], "mood": "calm|energetic|dark|bright"}""" output = Path("results.jsonl") t0 = time.time() results, failed = await run_batch(image_paths, prompt, output, concurrency=20) elapsed = time.time() - t0 ok = sum(1 for r in results if r.status == "ok") print(f"\nCompleted in {elapsed/60:.1f} minutes") print(f"Success: {ok}/{len(image_paths)} | Failed: {len(failed)}") print(f"Results saved to: {output}") if failed: print(f"Failed paths written to: failed_paths.txt") Path("failed_paths.txt").write_text("\n".join(failed))asyncio.run(main())
Monitoring Actual API Costs During Development
One thing that surprised me when I first ran large batches: the Gemini API cost reporting in Google AI Studio lags by several minutes. You can't watch costs in real time. The only reliable way to track spending during a long batch is to instrument your own code.
Here is a lightweight cost estimator based on the current gemini-2.5-flash pricing (input images ~$0.000038 each in the standard tier):
import asynciofrom dataclasses import dataclass, field@dataclassclass CostTracker: """ Minimal cost tracker for Gemini API batch jobs. Prices based on gemini-2.5-flash May 2026 standard tier. Update IMAGE_COST_USD and TOKEN_COST_USD when pricing changes. """ IMAGE_COST_USD: float = 0.000038 # per image input TOKEN_COST_USD: float = 0.00000015 # per output token (rough estimate) total_images: int = 0 total_output_tokens: int = 0 lock: asyncio.Lock = field(default_factory=asyncio.Lock) async def record(self, output_text: str): token_estimate = len(output_text.split()) * 1.3 # rough token count async with self.lock: self.total_images += 1 self.total_output_tokens += int(token_estimate) @property def estimated_cost_usd(self) -> float: return ( self.total_images * self.IMAGE_COST_USD + self.total_output_tokens * self.TOKEN_COST_USD ) def summary(self) -> str: return ( f"Images processed: {self.total_images} | " f"Estimated cost: ${self.estimated_cost_usd:.4f} USD" )
Wire it into your processing loop:
tracker = CostTracker()async def _process_one_tracked(path: str) -> dict: async with semaphore: with open(path, "rb") as f: image_data = f.read() result = await call_gemini_with_retry(prompt, image_data) await tracker.record(result) return {"path": path, "status": "ok", "result": result}# After the batch completes:print(tracker.summary())# → Images processed: 1200 | Estimated cost: $0.0524 USD
The estimate won't match your invoice exactly — actual token counts differ from word-count estimates, and image pricing can vary by resolution and model version — but it gives you a real-time signal to catch runaway cost before the job finishes. My 1,200-image run estimated $0.053; the actual Google invoice was $0.058.
Next Steps
The pipeline I'm running today has two additional layers on top of this: a Redis cache that skips API calls for images already classified in previous runs, and a dead-letter queue for the small percentage of images that fail even after five retries. Both are optional depending on your volume and error tolerance.
If you're starting fresh, the recommended path is:
Run the basic Semaphore pattern against 50 images with concurrency=5. Verify the results.
Scale concurrency up to 20 and run against 500 images. Monitor for 429s.
Tune concurrency based on your API tier and acceptable error rate.
Add the JSONL write-on-complete pattern once you're running 1,000+ image batches.
The code in this article is derived from the actual pipeline I maintain for Beautiful 4K/HDR Wallpapers. It runs regularly against batches of 500–2,000 images, and it has been stable since February 2026. The same patterns apply to any Gemini API use case that involves processing lists of inputs — documents, audio files, structured data records — not just images.
If you're working on a similar pipeline, I hope the pitfall section saves you the hours it cost me.
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.