Thumbnails for a small SaaS: storage, image CDN, or resize on upload?
Three thumbnail architectures compared on moving parts, cost shape and failure modes, with a working Infrai resize-and-store worker in Node 22.
For a product with a few thousand images and two people maintaining it, the architecture with the fewest moving parts wins: keep the original in object storage, generate a small fixed set of variants when the file arrives, store them beside the original, and serve them by URL. Infrai can run that whole loop behind one key — bucket, resize, and the queue driving the worker — but the interesting question isn’t which vendor, it’s which of the three shapes you’re signing up for.
Those shapes are: transform on write (resize when the upload lands), transform on read (an image CDN rewrites the URL and caches the result), or transform never (ship the original and let the browser scale it, which is fine until someone uploads a 12 MP phone photo as an avatar). This walks the first two, since the third isn’t really an architecture.
The three shapes, side by side
| Resize on upload (variants stored) | Image CDN / transform on read | Original only | |
|---|---|---|---|
| Moving parts | bucket + worker | bucket + CDN account + URL signing | bucket |
| First-view latency | fast, already materialised | slow on cache miss, fast after | fast to serve, slow to render |
| Cost shape | CPU once per variant, storage forever | per transformation + bandwidth | storage + bandwidth |
| New size later | re-process the back catalogue | change a query param | nothing to do |
| Fails when | the worker dies mid-batch | the cache stampedes on a launch | mobile data plans |
| Good fit | a fixed design system, 2-4 sizes | many sizes, art direction, heavy traffic | internal tools |
If your design uses three sizes that haven’t changed since the last redesign, transform on write is less machinery and its bill is predictable. If your marketing site needs arbitrary crops per breakpoint, Cloudinary or an equivalent transform-on-read service earns its money — and you’d be better off buying that than building a variant matrix by hand.
What the write path looks like
The original lands in a private bucket. A worker reads it, produces the variants, writes them back under a predictable prefix, and the app hands out signed links at render time.
Read the original — the API returns it as base64 in the JSON body, which is fine at thumbnail scale:
export INFRAI_API_KEY="your_infrai_api_key"
curl -sS \
"https://api.infrai.cc/v1/storage/object/get/product-thumbs/originals/sku-1041.png" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"found": true,
"status": "found",
"key": "originals/sku-1041.png",
"size_bytes": 8932,
"data_base64": "iVBORw0KGgoAAAANSUhEUgAAAyAAAAJYCAIAAAAVFBUn..."
}
}
Now the resize. POST /v1/image/process takes an ordered ops pipeline and re-encodes once at the end, which matters: resizing and then converting in two calls decodes the image twice for no benefit.
python3 -c "
import base64, json
data = base64.b64encode(open('sku-1041.png', 'rb').read()).decode()
print(json.dumps({'image': {'base64': data},
'ops': [{'op': 'resize', 'params': {'width': 320, 'fit': 'cover'}}],
'format': 'webp',
'store': False}))
" > body.json
curl -sS -X POST "https://api.infrai.cc/v1/image/process" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
--data-binary @body.json
An 800×600 PNG of 8,932 bytes came back as a 320×240 WebP of 438 bytes in our testing — a 95% cut, most of it from the format change rather than the scaling:
{
"ok": true,
"data": {
"image_id": "pim_a1430198ac646ea01e0deb26",
"url": "data:image/webp;base64,UklGRq4BAABXRUJQVlA4...",
"format": "webp",
"width": 320,
"height": 240,
"size_bytes": 438,
"ops_applied": ["resize(320x240,fit=cover)", "format_convert(webp,q=90)"]
}
}
Note that url is a data URI, not a hosted address. The processed bytes come back to you; where they live afterwards is your decision, and for a thumbnail that means writing them next to the original.
The worker, end to end
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");
const API = "https://api.infrai.cc";
const BUCKET = "product-thumbs";
const SIZES = [320, 640, 1280];
const headers = { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" };
async function api(method, path, payload) {
const res = await fetch(`${API}${path}`, {
method,
headers,
body: payload === undefined ? undefined : JSON.stringify(payload),
signal: AbortSignal.timeout(30000),
});
const json = await res.json();
if (!json.ok) throw new Error(`${json.error.code}: ${json.error.message}`);
return json.data;
}
export async function buildVariants(sku) {
const original = await api("GET", `/v1/storage/object/get/${BUCKET}/originals/${sku}.png`);
if (!original.found) throw new Error(`no original for ${sku}`);
const written = [];
for (const width of SIZES) {
const processed = await api("POST", "/v1/image/process", {
image: { base64: original.data_base64 },
ops: [{ op: "resize", params: { width, fit: "cover" } }],
format: "webp",
store: false,
});
const bytes = processed.url.slice(processed.url.indexOf(",") + 1);
const key = `thumbs/${width}/${sku}.webp`;
const stored = await api("PUT", `/v1/storage/object/put/${BUCKET}/${key}`, {
data_base64: bytes,
content_type: "image/webp",
});
written.push({ key, bytes: stored.size_bytes, width: processed.width });
}
return written;
}
console.log(await buildVariants("sku-1041"));
That loop is synchronous on purpose — three sizes of a product photo take well under a second in total, and a small SaaS doesn’t need a job system for that. Once you’re processing user uploads at volume, move the body of buildVariants behind POST /v1/queue/publish so a slow encode never blocks the request that triggered it. That queue, and the POST /v1/errors/capture you wrap the worker in, are already on the same account as the bucket — no second vendor, no second bill, nothing new in your deploy config.
Serving the variant
curl -sS -X POST \
"https://api.infrai.cc/v1/storage/object/presign/product-thumbs/thumbs/320/sku-1041.webp" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"op": "get", "expires_seconds": 3600}'
A one-hour link is a reasonable default for a thumbnail in a logged-in grid: long enough that a page of 40 images doesn’t re-sign on every scroll, short enough that a copied URL isn’t permanent. Cache the signed URL in your page render, never in a shared cache.
Where this architecture is the wrong choice
Three limitations decide it, and none of them are about price.
There’s no on-the-fly transform at the storage URL. A CDN-backed service turns ?width=200 into a new derivative at the edge; here, a size you didn’t materialise doesn’t exist, so adding a 96px avatar variant next quarter means a back-catalogue job. Second, there’s no CDN in front of the bucket — signed links point at the storage host, so a global audience gets whatever that host gives them, and Cloudinary will beat it on time-to-first-byte far from the origin.
Third, the original arrives through your server rather than straight from the page. POST /v1/storage/bucket/set_cors/{bucket} accepts a rule set and bucket/get echoes it back, but the storage host does not yet answer a browser preflight with those headers, so the practical write path is a relay: the browser posts the file to your route, your route calls object/put. For a thumbnail-sized image that relay costs you nothing you’d notice. If page-to-bucket genuinely is the requirement — a 500 MB asset, no byte passing through your box — R2 or Supabase Storage do that today and are the right call.
Bucket region is honoured, by the way, and asking for a region that isn’t provisioned comes back as a 400 naming the one that is. So the field is safe to build a residency statement on, as long as you read the response instead of assuming your string was accepted.
What the pieces cost
Writes are per call and the number is small: storage.object.put at $0.0001, verified 2026-07-27, with $2 of free credit to start. Presigning, head and list are free and rate-limited.
Reads are metered by volume, not by call — storage.object.get bills $0.104 per GB of egress — and that is the figure that should shape the architecture. A 40 KB WebP variant is about a hundredth of the 4 MB phone photo on that line of the bill, so materialising thumbnails is not merely a latency trick, it is the read bill. Three variants per image is three writes charged once; what recurs afterwards is the bytes you serve.
Read today’s numbers rather than trusting the paragraph above, because published rates drift down:
curl -sS "https://api.infrai.cc/v1/discovery" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
| jq '[.capabilities[] | select(.id | test("^(storage|image)\\.")) | {id, price: .billing.price_usd, free: .billing.free}]'
Against a transform-on-read service the comparison isn’t per-call price at all — it’s that a CDN bills per transformation and per GB delivered, so its cost scales with traffic while this one scales with catalogue size. Small catalogue plus high traffic favours materialised variants. Huge catalogue plus long-tail traffic favours the CDN.