In May 2026, I was simultaneously pushing major updates across four iOS apps — Beautiful HD Wallpapers, Ukiyo-e Wallpapers, Relaxing Healing, and Law of Attraction Everyday. The update involved new iPhone resolution support, AdMob mediation expansion, and StoreKit 2 migration. Amid all that, one task turned out to be an unexpected time sink: regenerating App Store descriptions across 12 languages.
Metadata for these apps lives in 12 languages. Every model swap or concept change means updating all of them, and the sequential script I had been using was painfully slow. Switching to asyncio with the Gemini API cut processing time to roughly one-twelfth of what it was. Here's how I did it.
One note before we start: this article has been rewritten since it was first published. The google-generativeai package I used at the time is now deprecated in favor of the unified google-genai SDK. The parallelization strategy hasn't changed at all, but the code has. I've kept both versions so you can see exactly what moved.
The Problem: 12 Languages × 4 Apps = 13 Minutes of Waiting
My original script used a simple for loop to generate descriptions one language at a time.
The snippet below is the original from May 2026, written against the legacy SDK. I wouldn't write it this way today, but it shows where the time actually went.
# ⚠️ Legacy SDK (google-generativeai — deprecated). Shown as historical context.
import google.generativeai as genai
import time
genai.configure(api_key="YOUR_GEMINI_API_KEY")
model = genai.GenerativeModel("gemini-2.5-flash")
LANGUAGES = [
"Japanese", "English", "Chinese (Simplified)", "Chinese (Traditional)",
"Korean", "French", "German", "Spanish", "Portuguese", "Italian",
"Arabic", "Russian"
]
def generate_description(app_name: str, lang: str) -> str:
prompt = f"""
Write an App Store description for "{app_name}" in {lang}.
Keep it under 4000 characters. Focus on beauty, relaxation, and daily use.
"""
response = model.generate_content(prompt)
return response.text
# Sequential — slow
results = {}
start = time.time()
for lang in LANGUAGES:
results[lang] = generate_description("Beautiful HD Wallpapers", lang)
print(f"✓ {lang} done")
elapsed = time.time() - start
print(f"\nTotal: {elapsed:.1f}s") # Measured: ~780 seconds (13 minutes)About 780 seconds for 12 languages. Multiply that by 4 apps and you're looking at nearly an hour per update cycle — not a workflow I wanted to repeat.
Profiling the loop makes the waste obvious. Of the roughly 60 seconds each request took, my Python process was busy for a few dozen milliseconds. Everything else was time spent waiting on a socket.
Why asyncio Rather Than threading
threading is a reasonable alternative, but Gemini API calls are dominated by network I/O, not CPU work. For that shape of workload, asyncio is lighter and easier to reason about.
Threads each carry their own stack, and you end up hand-managing synchronization with locks or queues. With asyncio, a single Semaphore controls concurrency, and error handling stays in ordinary try/except form. For a solo project where maintenance cost matters more than raw throughput, that simplicity wins.
The SDK also supports async calls natively. The legacy SDK exposed generate_content_async(); the current google-genai SDK mirrors every method under client.aio. Either way, moving from synchronous code doesn't require restructuring your logic.
multiprocessing isn't needed here. It shines for CPU-bound work like image processing, but API calls are I/O-bound.
Parallelizing with asyncio.gather()
Here's the current-SDK version. The Semaphore caps concurrency, which doubles as rate limit protection.
# Current SDK: pip install -U google-genai
import asyncio
import time
from google import genai
# Reads GEMINI_API_KEY from the environment
client = genai.Client()
MODEL = "gemini-3.7-flash"
LANGUAGES = [
"Japanese", "English", "Chinese (Simplified)", "Chinese (Traditional)",
"Korean", "French", "German", "Spanish", "Portuguese", "Italian",
"Arabic", "Russian"
]
async def generate_description_async(
app_name: str,
lang: str,
semaphore: asyncio.Semaphore
) -> tuple[str, str]:
"""
Generate a description with concurrency limited by semaphore.
Returns (language, description) tuple.
"""
async with semaphore:
prompt = f"""
Write an App Store description for "{app_name}" in {lang}.
Keep it under 4000 characters. Focus on beauty, relaxation, and daily use.
"""
# client.aio is the async entry point
response = await client.aio.models.generate_content(
model=MODEL,
contents=prompt,
)
return lang, response.text
async def generate_all_languages(app_name: str) -> dict[str, str]:
# Cap at 5 concurrent requests to stay within RPM limits
semaphore = asyncio.Semaphore(5)
tasks = [
generate_description_async(app_name, lang, semaphore)
for lang in LANGUAGES
]
results = await asyncio.gather(*tasks, return_exceptions=True)
output = {}
for result in results:
if isinstance(result, Exception):
print(f"❌ Error: {result}")
else:
lang, text = result
output[lang] = text
print(f"✓ {lang} done")
return output
start = time.time()
results = asyncio.run(generate_all_languages("Beautiful HD Wallpapers"))
elapsed = time.time() - start
print(f"\nTotal: {elapsed:.1f}s") # Measured: ~65 secondsThe measured result was around 65 seconds — down from 780. That's roughly a 12x speedup.
Only three lines changed between SDK generations. Instead of passing a model object around, you hold one client, and await model.generate_content_async(prompt) became await client.aio.models.generate_content(model=..., contents=...). The Semaphore and gather() structure are untouched.
Pitfall 1: 429 Rate Limit Errors Under Concurrency
The more you parallelize, the more rate limit errors you'll see. The Gemini API free tier has a relatively low RPM cap, and even paid tiers have limits that matter when you're firing 48 requests in parallel. When I pushed the Semaphore to 10, several languages came back exhausted.
There's an important change here that trips people up when they follow older articles. The current google-genai SDK retries transient failures on its own — 429s and 503s included — starting at roughly a one-second delay, backing off exponentially up to 60 seconds, for up to four attempts by default. The hand-rolled backoff wrapper that used to be mandatory now stacks on top of the SDK's own retries if you write it the same way.
A wrapper is still worth having, but for a different job: catching what's left after the SDK gives up, so you can requeue that one language instead of losing it.
import asyncio
from google import genai
from google.genai import errors
client = genai.Client()
async def generate_with_fallback(
prompt: str,
lang: str,
semaphore: asyncio.Semaphore,
) -> tuple[str, str | None]:
"""
Return None only when the SDK's built-in retries are already exhausted,
so the caller can requeue this language.
"""
async with semaphore:
try:
response = await client.aio.models.generate_content(
model="gemini-3.7-flash",
contents=prompt,
)
return lang, response.text
except errors.ClientError as e:
# 429 RESOURCE_EXHAUSTED lands here
if e.code == 429:
print(f"⚠️ {lang}: rate limited — requeuing")
return lang, None
raise
except errors.ServerError as e:
print(f"⚠️ {lang}: server error ({e.code}) — requeuing")
return lang, NoneThe exception types moved too. The legacy SDK raised google.api_core.exceptions.ResourceExhausted; the current SDK consolidates errors under google.genai.errors as ClientError (4xx) and ServerError (5xx), both subclasses of APIError. Since e.code carries the HTTP status, checking for 429 directly is the cleanest path.
The right Semaphore value depends on your plan. On the free tier, 3 was stable. On paid plans, 5 has been fine.
Pitfall 2: Error Handling with asyncio.gather()
By default, asyncio.gather() without return_exceptions=True will propagate the first exception and cancel remaining tasks. That means one failed language kills the whole batch.
# ❌ Dangerous: one error stops everything
results = await asyncio.gather(*tasks)
# ✅ Safe: errors are returned as values, not raised
results = await asyncio.gather(*tasks, return_exceptions=True)
for result in results:
if isinstance(result, Exception):
print(f"Skipping due to error: {result}")
else:
lang, text = result
output[lang] = textWith 12 languages in flight, I'd rather get 11 successful results and log one failure than lose everything to a single timeout.
Running All 4 Apps in One Pass
The final version handles all 4 apps at once — 48 requests total.
APPS = [
"Beautiful HD Wallpapers",
"Ukiyo-e Wallpapers",
"Relaxing Healing",
"Law of Attraction Everyday"
]
async def generate_all_apps():
semaphore = asyncio.Semaphore(5) # 5 concurrent across ALL apps
all_tasks = []
for app in APPS:
for lang in LANGUAGES:
all_tasks.append(
generate_description_async(app, lang, semaphore)
)
results = await asyncio.gather(*all_tasks, return_exceptions=True)
# Organize results into {app: {lang: text}} structureIn practice, 48 requests complete in 90–120 seconds. The sequential equivalent would have been over 50 minutes.
The detail that matters: one Semaphore for the entire run, not one per app. Per-app semaphores would give you four groups of five, which is twenty concurrent requests walking straight into the rate limiter. Concurrency has to be budgeted against the total number of in-flight requests.
What the SDK Migration Actually Touched
Here's the diff, condensed. The whole migration took about half an hour.
| Area | Legacy: google-generativeai | Current: google-genai |
|---|---|---|
| Install | pip install google-generativeai | pip install -U google-genai |
| Init | genai.configure(api_key=...) | client = genai.Client() (reads GEMINI_API_KEY) |
| Model selection | Build genai.GenerativeModel("...") up front | Pass model= per call |
| Async call | await model.generate_content_async(prompt) | await client.aio.models.generate_content(model=..., contents=...) |
| Generation options | Individual method arguments | Consolidated into config=types.GenerateContentConfig(...) |
| Exceptions | google.api_core.exceptions | google.genai.errors (ClientError / ServerError) |
| Retries | Roll your own | Built in (up to 4 attempts, exponential backoff) |
The one that cost me real time was spelling. The legacy embedding call took content (singular); the current SDK standardizes on contents (plural). I stared at that single character for longer than I'd like to admit.
I also revisited the model name. The gemini-2.5-flash from the original script belongs to an older generation, so new code should target a current Flash model. Keeping the model ID in a single constant makes that a one-line change instead of a grep expedition. The full migration reference lives in Google's GenAI SDK migration guide.
Don't Skip Quality Review Just Because It's Fast
Speed has a side effect: it's tempting to ship output you haven't actually read. When I was generating one language at a time, odd phrasing was easy to catch. With everything arriving at once, it's easy to just scroll past.
Two languages needed close attention in my case. Arabic is right-to-left, so line wrapping in the App Store listing behaves differently from every other locale — grammatically correct output can still look wrong on the actual store page. Traditional Chinese sometimes reads like converted Simplified text, which feels slightly stiff for the Taiwan storefront.
Neither check gets faster through parallelization. But reviewing results after 65 seconds instead of 13 minutes leaves you with the patience to actually do it.
Saving generated output to disk matters too. Gemini responses vary between runs, so once you get a good one, version it. I store descriptions as app_descriptions/{app_name}/{lang}/v{date}.txt and diff against the previous version on each update.
Cutting the Wait Removes the Compromises
A script that takes 13 minutes becomes something you put off. A script that takes 65 seconds becomes something you run frequently — and when you run it frequently, you actually review the output and improve it.
After parallelizing, I started checking each language individually and made small quality fixes I would previously have waved through with "no time, ship it." The speedup itself matters less than the slack it creates.
If you're calling the Gemini API synchronously today, the migration path is short: switch to client.aio.models.generate_content(), wrap the call in an async def, add a Semaphore, and use asyncio.gather(). Most of the logic stays exactly where it is.
Start with Semaphore(3) and a three-language batch. Time one run before you scale up — having that number makes the effect of higher concurrency visible instead of theoretical. Current async examples live in the Gemini API library documentation.