The generative-media price collapse means the model is no longer the hard part of shipping an image feature. Nano Banana 2 Lite makes an image for three-thousandths of a cent; Seedream 5.0 Pro and FLUX sit a tier up for when quality matters. The hard part is everything around the call: not paying twice for the same image, not going down when one vendor does, and not waking up to a four-figure invoice.
That plumbing is the part you actually own — the models are commodities now — and it's about 120 lines of TypeScript. Here's the whole thing, in four steps. Adapt the SDK calls to your providers; the structure is what matters.
Step 1 — Put a content-addressed cache in front of every call#
Every generation costs money every time. In any real product a large share of requests repeat an identical prompt — the same product shot, the same avatar seed, a retried request after a network blip. Serving those from a cache is the single biggest cost lever you have, and it's a dictionary lookup.
The key insight: the cache key is a hash of the inputs, not a random ID. Same prompt + size + model → same key → same stored image.
// cache-key.ts — content-addressed key for a generation request
export async function genKey(req: {
prompt: string; size: string; model: string;
}): Promise<string> {
const canonical = JSON.stringify([req.model, req.size, req.prompt.trim()]);
const bytes = new TextEncoder().encode(canonical);
const digest = await crypto.subtle.digest("SHA-256", bytes);
return [...new Uint8Array(digest)]
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
Store the result in object storage (S3/R2/GCS) under images/<key>.png, and check for the object before you ever hit a provider:
// cache.ts
import { genKey } from "./cache-key";
export async function getCached(req: GenReq, store: ObjectStore) {
const key = await genKey(req);
const hit = await store.head(`images/${key}.png`); // cheap existence check
return hit ? { url: store.publicUrl(`images/${key}.png`), key, cached: true } : { key, cached: false };
}
That head check is the difference between a bill that scales with unique demand and one that scales with raw traffic.
Step 2 — Wrap generation in a provider fallback chain#
You don't control the model. A preview API stalls, a safety filter refuses a benign prompt, a region gets blocked, a price doubles. Hardwire one vendor and any of those is an outage. A fallback chain turns each into a quiet quality degradation.
Give every provider the same signature — (req) => Promise<Buffer> — so they're interchangeable. Here's the cheap primary (Nano Banana 2 Lite via the Gemini API) and a quality secondary (via fal):
// providers.ts
type Provider = { name: string; costPer: number; gen: (req: GenReq) => Promise<Buffer> };
// Primary: cheap + fast. ~$0.034 / 1,000 images.
const nanoBananaLite: Provider = {
name: "nano-banana-2-lite",
costPer: 0.000034,
async gen(req) {
const res = await fetch(
`https://generativelanguage.googleapis.com/v1beta/models/gemini-nano-banana-2-lite:generateContent?key=${process.env.GEMINI_API_KEY}`,
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
contents: [{ parts: [{ text: req.prompt }] }],
generationConfig: { responseModalities: ["IMAGE"] },
}),
},
);
if (!res.ok) throw new Error(`nano-banana ${res.status}`);
const json = await res.json();
const b64 = json.candidates?.[0]?.content?.parts?.find((p: any) => p.inlineData)?.inlineData?.data;
if (!b64) throw new Error("nano-banana: no image in response"); // safety refusal etc.
return Buffer.from(b64, "base64");
},
};
// Secondary: higher quality / better text. Swap the model slug for FLUX or Seedream.
const falSecondary: Provider = {
name: "fal-seedream-5-pro",
costPer: 0.003,
async gen(req) {
const res = await fetch("https://fal.run/fal-ai/seedream/v5/pro", {
method: "POST",
headers: { authorization: `Key ${process.env.FAL_KEY}`, "content-type": "application/json" },
body: JSON.stringify({ prompt: req.prompt, image_size: req.size }),
});
if (!res.ok) throw new Error(`fal ${res.status}`);
const { images } = await res.json();
const img = await fetch(images[0].url);
return Buffer.from(await img.arrayBuffer());
},
};
export const CHAIN: Provider[] = [nanoBananaLite, falSecondary]; // order = fallback order
Model IDs and request shapes drift — check each provider's current docs and pin the exact slug. The structure (uniform signature, ordered list) is the durable part.
Now the orchestrator: cache → walk the chain → static placeholder as the last resort. It never throws to the caller.
// generate.ts
import { getCached, genKey } from "./cache";
import { CHAIN } from "./providers";
const PLACEHOLDER = "/static/img-unavailable.png";
export async function generate(req: GenReq, store: ObjectStore, meter: Meter) {
const cached = await getCached(req, store);
if (cached.cached) return { url: cached.url, source: "cache", cost: 0 };
for (const p of CHAIN) {
if (!meter.canSpend(p.costPer)) break; // Step 3: respect the cap
try {
const buf = await withTimeout(p.gen(req), 15_000);
const url = await store.put(`images/${cached.key}.png`, buf, "image/png");
meter.record(p.costPer);
return { url, source: p.name, cost: p.costPer };
} catch (err) {
console.warn(`[img] ${p.name} failed, falling through:`, (err as Error).message);
}
}
return { url: PLACEHOLDER, source: "placeholder", cost: 0 }; // fail closed, never 500
}
The for…of over an ordered chain is the entire fallback. Add a third provider by pushing to CHAIN; there's nothing else to change.
Step 3 — Meter every call and cap the spend#
A generation endpoint is a spend endpoint. It needs two independent limits: a per-user rate limit (stops one client from generating thousands of images) and a global daily spend ceiling (stops the aggregate from ever passing a number you chose). The orchestrator above already asks meter.canSpend() before each provider; here's a minimal meter:
// meter.ts — global daily spend cap; back it with Redis in production
export class Meter {
constructor(private dailyCapUsd: number, private spentToday = 0) {}
canSpend(cost: number) { return this.spentToday + cost <= this.dailyCapUsd; }
record(cost: number) { this.spentToday += cost; }
remaining() { return this.dailyCapUsd - this.spentToday; }
}
Pair it with a per-user token bucket at the API layer, and alert yourself at ~70% of the cap so a real spike is a decision you make, not a bill you discover. When the cap is hit, generate() already falls through to the placeholder — the feature degrades, it doesn't melt down.
Step 4 — Keep generation off the request path#
Unless the user is explicitly waiting for that image and nothing else, don't block a render on a 4-second model. Enqueue the job, return the placeholder immediately, and swap the real URL in when it's ready:
// route.ts
app.post("/api/image", async (req, res) => {
const key = await genKey(req.body);
const cached = await getCached(req.body, store);
if (cached.cached) return res.json({ url: cached.url, status: "ready" });
await queue.enqueue("generate-image", { req: req.body, key }); // background worker calls generate()
res.json({ url: PLACEHOLDER, key, status: "pending" }); // client polls /api/image/:key
});
Your p95 page latency is now independent of the model's latency — which matters more, not less, as you add providers with different speeds. (If you'd rather not run the queue and worker yourself, a durable-jobs service like Trigger.dev gives you the enqueue-with-retries half of this for free.)
The takeaway#
The models repriced; your job didn't. Cache so you never pay twice, chain providers so no single vendor can take you down, cap so no single day can surprise you, and enqueue so no single slow call stalls a page. It's ~120 lines, it's provider-agnostic, and it's the part of an image feature that's actually yours — the piece that's still true after the next cheaper model ships next week.



