●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
Designing an Image Pipeline with Gemini Files API and Cloudflare R2 — Notes from Running a Wallpaper App
Notes from rebuilding the image processing pipeline of a wallpaper app around Gemini Files API and Cloudflare R2. Covers the 48-hour TTL, idempotent retries, and cost monitoring, with implementation code and 30 days of numbers.
I'm Masaki Hirokawa, an indie developer and contemporary artist. Since 2013, I've been running wallpaper apps that have crossed 50 million downloads in total. Once the new-image intake reached a few hundred per day, the small inefficiencies in my pipeline started showing up at the end of the month.
The first version simply uploaded each image to the Gemini Files API on every job. Files there expire after 48 hours, and any naive retry would re-upload the same bytes again and again. I spent about a month rewriting the pipeline so that Cloudflare R2 owns the long-lived bytes and Gemini Files API only holds short-lived processing references. These are the notes from that rebuild.
It started the day I realised I was uploading the same image three times
In early March 2026, something looked off in my admin dashboard. About 400 new images were being added per day, but the Gemini Files API invoices were charging me for almost three times that many uploads.
The reason became obvious quickly. The Files API auto-deletes files after 48 hours, and my old pipeline retried on any error, including transient network blips and short-lived 5xx responses. The same bytes were being uploaded over and over.
The lesson I took from that week was simple. Files API is best treated as a per-session scratch space, not as persistent storage. Persistence belongs in another layer, and Cloudflare R2 turned out to fit that role nicely.
How I split responsibilities between Files API and R2
The shape I ended up with is a two-layer storage:
Layer
Role
Lifetime
Cost feel
Cloudflare R2
Permanent home for the image bytes, also serves them
Forever
Free egress, ~¥2/GB·month
Gemini Files API
Processing-session reference for Gemini calls
48 hours
Per-upload charge
I anchored the design around three principles:
Every image is written to R2 first; the R2 object key is the canonical address
Files API is only touched at the moment Gemini needs to read the image, and the reference is treated as disposable
Retries cache the fileUri; we never re-upload to Files API if a valid reference still exists
The upload helper that came out of these rules looks like this in TypeScript on Cloudflare Workers:
// src/lib/image-pipeline/upload.tsimport { GoogleGenerativeAI } from "@google/generative-ai";import { createHash } from "node:crypto";interface UploadResult { fileUri: string; // Gemini Files API reference fileHash: string; // SHA-256 of the bytes expiresAt: number; // Unix milliseconds r2Key: string; // canonical object key}export async function ensureGeminiFile( bytes: Uint8Array, mimeType: string, kv: KVNamespace, // Cloudflare KV (metadata) bucket: R2Bucket, // Cloudflare R2 (bytes) apiKey: string,): Promise<UploadResult> { const fileHash = sha256(bytes); const cacheKey = `gemini-file:${fileHash}`; const r2Key = `originals/${fileHash}.bin`; // 1) Persist to R2 if missing (put is naturally idempotent) if (!(await bucket.head(r2Key))) { await bucket.put(r2Key, bytes, { httpMetadata: { contentType: mimeType } }); } // 2) Try to reuse the Files API reference from KV const cached = await kv.get<UploadResult>(cacheKey, "json"); if (cached && cached.expiresAt > Date.now() + 5 * 60_000) { return cached; // reuse if at least 5 minutes left } // 3) Upload to Files API only when we have to const genAI = new GoogleGenerativeAI(apiKey); const file = await genAI.files.upload({ file: new Blob([bytes], { type: mimeType }), config: { displayName: fileHash }, }); const result: UploadResult = { fileUri: file.uri, fileHash, expiresAt: Date.now() + 47 * 60 * 60_000, // treat as expired one hour early r2Key, }; await kv.put(cacheKey, JSON.stringify(result), { expirationTtl: 47 * 3600 }); return result;}function sha256(b: Uint8Array): string { return createHash("sha256").update(b).digest("hex");}
R2 writes are naturally idempotent because the key is the content hash. The Files API reference is cached in KV with a 47-hour TTL. The deliberate one-hour margin matters: if you cache for the full 48 hours and the reference is consumed at the very last minute, Gemini will tell you the file has already expired.
✦
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
✦A two-layer storage design that treats Files API as a 48-hour processing session and R2 as the source of truth
✦Hash-keyed idempotency that prevents the same bytes from being uploaded twice across retries
✦Rate-limited retries and a three-metric dashboard that brought monthly waste down to a few thousand yen
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.
Idempotent retries through hash-keyed deduplication
The biggest source of waste in my old setup was retries that re-uploaded the same bytes. Rebuilt, retries now check at two layers:
Workflow layer: deduplicates job submissions by fileHash
Upload layer: checks the KV cache before issuing a real PUT to Files API
// src/lib/image-pipeline/worker.tstype Job = { id: string; bytes: Uint8Array; mime: string };export async function processJob( job: Job, env: Env,): Promise<{ caption: string; tags: string[] }> { const file = await ensureGeminiFile( job.bytes, job.mime, env.IMG_KV, env.IMG_BUCKET, env.GEMINI_API_KEY, ); // If we already have a Gemini answer for this hash, return it const resultKey = `caption:${file.fileHash}`; const cached = await env.IMG_KV.get(resultKey, "json"); if (cached) return cached as { caption: string; tags: string[] }; const genAI = new GoogleGenerativeAI(env.GEMINI_API_KEY); const model = genAI.getGenerativeModel({ model: "gemini-2.5-flash" }); const resp = await model.generateContent([ { fileData: { fileUri: file.fileUri, mimeType: job.mime } }, { text: "Return a short caption and five distribution tags as JSON for this wallpaper image." }, ]); const json = parseJsonLoosely(resp.response.text()); await env.IMG_KV.put(resultKey, JSON.stringify(json), { expirationTtl: 90 * 86400 }); return json;}
The caption:{hash} cache lives for 90 days. If the same image arrives through another path, no Gemini call is made. In practice, duplicate submissions show up more often than you'd expect; this single cache cut my daily Gemini call count by roughly 22% on its own.
Rate limiting designed for failure, not for the happy path
Gemini API enforces RPM, TPM, and daily caps. With Flash you can still trip 429s if the workload spikes. On my busiest day, an unprotected queue spent 70% of the daily budget in a two-hour window in the afternoon.
The fix was a two-level backpressure setup:
Cloudflare Queues paces dispatching at the queue layer
The worker tracks in-flight concurrency and waits once on a 429 with exponential backoff
This is not sophisticated, but it stopped the cascade of 429-driven duplicate uploads. Re-upload spend dropped from about ¥7,800 in the old pipeline to ¥3,200 in April. Gemini processing cost did not go up; the cache actually pulled it down. The whole delta is wasted retries that no longer happen.
A three-metric dashboard I can actually keep up with
I tried to keep the dashboard small enough that I won't stop looking at it. Three metrics seem to be enough:
Metric
Threshold
What it tells me
Files API uploads / image ratio
< 1.05
How often we are re-uploading
Gemini failure rate (24h rolling)
< 1.5%
Whether the backpressure tuning is right
Cost per image
around ¥0.4
A proxy for cache hit rate
Writes go into Cloudflare Analytics Engine. Even with that minimal setup, patterns like "Friday evening error spikes" and "end-of-month cost bumps" became visible.
I write events at hour-level granularity. Per-day is too coarse for debugging, per-minute blows up storage in a few months. Hour buckets are the sweet spot for me.
A three-month cost recap
Here are the numbers from my own runs, side by side. Monthly downloads were flat across these three months, so the difference reflects the pipeline change.
Month
Setup
Files API uploads
Re-upload ratio
Gemini monthly
Per image
2026-02
Old (upload every time)
~51,000
~35%
¥18,400
¥1.40
2026-03
Transition (R2 added)
~33,000
~15%
¥12,900
¥0.95
2026-04
New (with KV cache)
~13,500
~5%
¥7,800
¥0.58
Per-image processing cost improved roughly 2.4x from February to April. The biggest single contributor was the result cache; the March-to-April delta is almost entirely explained by it.
Lined up against AdMob revenue per image, image-processing cost now sits at about 1.2% of revenue. As an indie developer, that's the headroom that lets me say yes to adding more images without hesitation.
Three potholes the first 30 days exposed
A few things only surfaced once the rebuilt pipeline was carrying real traffic.
The first: don't trust R2 head() too much. R2 is eventually consistent on a short window, and during intense bursts I saw a few head() misses right after a put. Since put is idempotent on the same key, the cleaner fix on hot paths was to skip head() and always put.
The second: don't use a raw hash as the Files API displayName. The first version put the bare SHA-256 there, which made the file list useless for debugging. Switching to <category>-<hash[:8]> made admin scans far easier.
The third, and most painful: a five-minute expiry margin is sometimes too small. During heavy late-night runs, Gemini occasionally queued requests for around 90 seconds, eating into the buffer. Rounding the TTL down to 47 hours instead has held up since, but I'd recommend giving yourself a longer cushion than you think you need.
A small suggestion if you want to try this shape
If you're running a few hundred to a few thousand images per day on an indie or small-team app, this shape should fit you well. The biggest single win, even before adding the KV cache, is putting R2 in front of Files API. That step alone usually moves the upload ratio noticeably.
Coming from a family of temple carpenters, I keep coming back to the same value when I think about engineering: never do the same work twice, and rebuild carefully when you can. The whole point of this pipeline is exactly that. Thanks for reading this far, and I hope these notes help on your own runs.
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.