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. Same key, no new vendor.
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 URLs point at the storage host, so a global audience gets whatever that region gives them, and Cloudinary or an image CDN in front of S3 will beat it on time-to-first-byte outside the storage region. Third, browser-direct upload isn’t practical yet because there’s no route to set bucket CORS, so the original has to arrive through your server or a native client.
One more, filed under “check it yourself”: requesting a bucket region records the region on the bucket but the signing host we saw on 2026-07-26 didn’t follow it, so don’t build a US/EU residency promise on that field without verifying the host you get back.
What the pieces cost
Storage API calls are metered per call and the numbers are small: storage.object.get at $0.0002, storage.object.put at $0.0001, verified 2026-07-26, with $2 of free credit to start. Presigning and head are free and rate-limited. Three variants per image is three writes plus one read — under a cent per hundred images — and the durable point is the shape rather than the digits: reads cost about twice writes, materialising variants is a one-off charge, and stored bytes are metered separately from calls.
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.