Picking object storage for AI-generated images in a SaaS
Renders arrive server-side, so browser upload rules don't apply. How S3, R2, B2 and Infrai compare on private delivery, egress and what one key buys you.
Generated images change the shape of this decision, and most comparison posts miss why. A render never comes from a user’s browser — it arrives at your backend as bytes or a temporary vendor URL, so CORS rules, presigned upload slots and multipart resumption are all somebody else’s problem. What’s left is narrower: cheap writes, private delivery, and how much plumbing sits between the model that made the picture and the bucket that keeps it. That last part is where Infrai’s storage is worth a look, since the same credential covers both ends.
If your images are small, private and produced server-side, this is a two-call feature. If you serve terabytes of them to the public, egress pricing will dominate everything else in this article.
The realistic shortlist
| Egress model | Signed URLs | S3-compatible API | Region you choose | Same key also runs | |
|---|---|---|---|---|---|
| Amazon S3 | Per GB, the reference price | Yes, with IAM policy behind them | Native | Yes, contractually | The rest of AWS |
| Cloudflare R2 | No egress charge | Yes | Yes | Automatic placement | Workers, KV |
| Backblaze B2 | Free up to a multiple of stored data | Yes | Yes | Yes | Nothing else |
| Wasabi | Included, with a fair-use policy | Yes | Yes | Yes | Nothing else |
| Infrai storage | Metered, per-call ops | Yes, 1s–7d TTL | Yes, via presigned URLs | Recorded, not enforced | Image ops, queues, cron, email, error tracking |
Read the last column as the actual differentiator, because the first four rows are near-identical technically. R2’s pricing page and S3’s are the numbers to compare when bandwidth is your dominant cost — no aggregator changes that arithmetic.
Generate, store, sign
The pipeline is three calls on one key. The OpenAI-compatible surface makes the image, object/put keeps it, and a presigned URL hands it to the browser.
const API = "https://api.infrai.cc";
const BUCKET = "kb-img-vault-0726";
const token = process.env.INFRAI_API_KEY;
if (!token) throw new Error("INFRAI_API_KEY is not set");
const headers = { Authorization: `Bearer ${token}`, "Content-Type": "application/json" };
export async function renderAndStore(tenantId, prompt) {
const request = { model: "auto", prompt, size: "1024x1024", n: 1 };
const generated = await fetch(`${API}/v1/images/generations`, {
method: "POST",
headers,
body: JSON.stringify(request),
});
const art = await generated.json();
if (!generated.ok) throw new Error(art?.error?.message ?? `HTTP ${generated.status}`);
const first = art.data[0];
const bytes = first.b64_json
? Buffer.from(first.b64_json, "base64")
: Buffer.from(await (await fetch(first.url, { method: "GET" })).arrayBuffer());
const key = `renders/${tenantId}/${Date.now()}-${Math.random().toString(16).slice(2, 8)}.png`;
const payload = { data_base64: bytes.toString("base64"), content_type: "image/png" };
const stored = await fetch(`${API}/v1/storage/object/put/${BUCKET}/${key}`, {
method: "PUT",
headers,
body: JSON.stringify(payload),
});
const saved = await stored.json();
if (saved.ok === false) throw new Error(saved.error.code);
return { key: saved.data.key, size_bytes: saved.data.size_bytes };
}
No S3 credentials, no bucket policy JSON, no second dashboard. That’s the whole argument for consolidation, and it’s worth exactly as much as the time you’d otherwise spend wiring two vendors together — for some teams that’s a morning, for others it’s a quarter of ongoing key rotation and invoice reconciliation.
Buckets are listed with a cursor, which matters once a multi-tenant app starts creating one per customer:
export INFRAI_API_KEY="your_infrai_api_key"
curl -sS "https://api.infrai.cc/v1/storage/bucket/list" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"items": [
{
"bucket_id": "bkt_ea28938d438b4787b8473b",
"name": "kb-img-vault-0726",
"vendor": "cos",
"region": "ap-singapore",
"acl": "private",
"cors_rules": [],
"lifecycle_rules": []
}
],
"next_cursor": "bkt_05fb4d03914a4bea8264b4"
}
}
Private delivery, and what “private” actually covers
curl -sS -X POST \
"https://api.infrai.cc/v1/storage/object/presign/kb-img-vault-0726/renders/job_9001/full.png" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"op":"get","expires_seconds":600}'
{
"ok": true,
"data": {
"url": "https://<vendor-host>/<bucket>/renders/job_9001/full.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=600&X-Amz-Signature=...",
"expires_at": "2026-07-26T01:13:26.297431Z"
}
}
Before handing a link to anyone, confirm the render is really there — head is free and returns the size and ETag without transferring the image:
curl -sS \
"https://api.infrai.cc/v1/storage/object/head/kb-img-vault-0726/renders/job_9001/full.png" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
Here’s where an honest comparison earns its keep. On S3 you can write a bucket policy that denies every unsigned request, and then the signature genuinely is the gate. On these buckets the object answers a plain GET without any signature at all, so the TTL is a convenience and an expiry, not an authorisation check. Generated images are usually low-stakes — a render of a product photo isn’t a payslip — but if yours are private in the legal sense, that’s a limitation you should weigh, and S3 with an explicit deny is the safer build.
Two more boundaries, stated plainly. region is recorded on the bucket but placement follows the vendor, so residency guarantees aren’t something this surface can offer. And there’s no route to set CORS rules, which means a browser can’t PUT directly to one of these buckets — irrelevant for server-side renders, disqualifying for a client-side image editor.
Cost, with the arithmetic that matters
Verified 26 July 2026: writes bill $0.0001 per call, reads through object/get $0.0002, and head, list, presign and deletes are free and rate-limited, with $2 of credit on a new account. For a gallery app writing 50,000 renders a month that’s about $5 in write calls — real money, but an order of magnitude below what the generation itself costs. Sustained heavy transfer can hit a ceiling, signalled by STORAGE_BANDWIDTH_EXCEEDED, which is the honest counterpart to R2’s zero-egress promise.
curl -sS https://api.infrai.cc/v1/discovery \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
| python3 -c "import json,sys; [print(c['id'], c['billing'].get('price_usd','free')) for c in json.load(sys.stdin)['capabilities'] if c['id'].startswith('storage.object')]"
Per-call rates tend to fall, and discount campaigns run, so pull that rather than trusting a figure typed in July. The relationship that survives price changes: storage operations here are cheap relative to inference, and the thumbnailing, moderation and delivery steps around them bill to the same account, which is why per-tenant image cost is a query instead of a reconciliation.
So which one
Serving generated images publicly at scale? Take Cloudflare R2 and stop reading — free egress beats every other consideration once you’re moving terabytes.
Need proven residency, object lock, or you’re already deep in AWS IAM? S3, without hesitation, and pay the per-GB transfer.
Building an image feature where the render, the resize, the moderation pass and the retry queue all need somewhere to live, and you’d rather not run five accounts to ship it? That’s the case where keeping storage next to everything else on one key is the cheaper engineering decision, even when the per-GB line isn’t the lowest on the market.