The simplest avatar storage for a Node SaaS, judged by setup steps
Avatars are 50-500 KB and never need multipart. Compare the S3-compatible options on how much configuration stands between you and the first stored byte.
For profile pictures, “simplest” is a real engineering criterion, not laziness. An avatar is 50-500 KB, it’s written once and read constantly, and nobody’s business depends on it — so the right backend is whichever one gets you from empty repo to stored byte with the least configuration. Infrai scores well on exactly that axis: one bearer token, one JSON call, no SDK, no IAM policy document. Cloudflare R2 and Amazon S3 win on other axes, and this page is honest about which.
Here’s the whole write path, with a real key, from a shell.
One call, no SDK
export INFRAI_API_KEY="your_infrai_api_key"
curl -sS -X POST "https://api.infrai.cc/v1/storage/bucket/create" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"name":"kb-avatars-simple","acl":"private"}'
PAYLOAD=$(python3 -c "
import base64, json, sys
raw = open('me.jpg','rb').read()
print(json.dumps({'content_base64': base64.b64encode(raw).decode(), 'content_type': 'image/jpeg'}))
")
curl -sS -X PUT \
"https://api.infrai.cc/v1/storage/object/put/kb-avatars-simple/avatars/u_1042/original.jpg" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d "${PAYLOAD}"
That’s it — bucket, object, done, and the bucket call is free. The object body travels as base64 inside JSON, which is the trade-off at the heart of this design: no SDK and no signing dance, at the cost of 33% more bytes on the wire and one extra encode.
For avatar-sized files that trade is fine. We measured a 240 KB image both ways, median of three runs: about 2.3 s through the base64 JSON write, about 1.0 s if you mint a presigned URL and PUT the raw bytes at it. Both are well inside what a profile-settings form can absorb. At 9 MiB the gap widens to roughly 10.4 s versus 2.5 s, and at 30 MiB the JSON path takes about a minute — which is why this recommendation is scoped to avatars and not to video.
The Node 22 version
import { readFile } from "node:fs/promises";
const API = "https://api.infrai.cc";
const BUCKET = "kb-avatars-simple";
export async function storeAvatar(userId, filePath, contentType = "image/jpeg") {
const bytes = await readFile(filePath);
if (bytes.byteLength > 2 * 1024 * 1024) throw new Error("avatar too large");
const key = `avatars/${userId}/original.${contentType === "image/png" ? "png" : "jpg"}`;
const payload = JSON.stringify({ content_base64: bytes.toString("base64"), content_type: contentType });
const res = await fetch(`${API}/v1/storage/object/put/${BUCKET}/${key}`, {
method: "PUT",
headers: { Authorization: `Bearer ${process.env.INFRAI_API_KEY}`, "Content-Type": "application/json" },
body: payload,
});
if (!res.ok) throw new Error(`avatar write failed ${res.status}: ${await res.text()}`);
const { data } = await res.json();
return { key: data.key, etag: data.etag, size: data.size_bytes };
}
Writing to the same key replaces the old image in place. There’s no version history to clean up and no orphan to sweep — which is the behaviour you want for avatars and the behaviour you emphatically don’t want for documents. The catch is caching: same key, same URL, so put the ETag or a short hash in the query string your app renders, or users will stare at their old face for a day.
Reading it back
Free metadata read, no body transferred:
curl -sS -X GET \
"https://api.infrai.cc/v1/storage/object/head/kb-avatars-simple/avatars/u_1042/original.jpg" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"found": true,
"status": "found",
"key": "avatars/u_1042/original.jpg",
"size_bytes": 2048,
"etag": "cfb767f225d58469c5de3632a8803958",
"content_type": "image/jpeg"
}
}
And a time-boxed link for the browser to render:
curl -sS -X POST \
"https://api.infrai.cc/v1/storage/object/presign/kb-avatars-simple/avatars/u_1042/original.jpg" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"op":"get","expires_seconds":3600}'
One caveat you should know before you design around it. On the buckets we tested, the object also answers a plain GET with the signature stripped off the URL — the ACL says private, and the bytes come back anyway. For avatars that’s usually acceptable, since a profile picture is semi-public by nature. For anything sensitive it isn’t, and you’d need to read through your own server so you can enforce a session check.
How the shortlist actually compares
| Option | Steps to first stored byte | SDK needed | Browser-direct upload | Built-in resize |
|---|---|---|---|---|
| Infrai storage | API key → bucket → PUT | No, plain REST | No — no CORS setter | No |
| Cloudflare R2 | Account → bucket → API token → S3 client → CORS rule | S3 SDK or equivalent | Yes | No, pair it with Images |
| Amazon S3 | Account → bucket → IAM policy → keys → SDK → CORS | S3 SDK | Yes | No |
| Supabase Storage | Project → bucket → RLS policy → client library | Supabase JS | Yes | Yes, image transforms |
| Cloudinary | Account → upload preset | Optional | Yes | Yes, that’s the product |
Two of those rows deserve a plain recommendation. If the browser must push bytes straight into the bucket with no proxy, Infrai can’t do it today and R2 or S3 can — the preflight OPTIONS on an Infrai bucket returns 403 and no route exists to add a CORS rule. And if what you actually need is “one URL, any crop, any format”, Cloudinary or Supabase’s image transforms will save you a worker; Infrai storage doesn’t support resizing, so you’d be running sharp yourself.
What tips it back the other way is everything around the avatar. The same key that writes the image also publishes to a queue, sends the welcome email, records the error if the write fails, and bills all of it to one account you can attribute per tenant. Avatar storage is rarely a standalone problem — it’s the first file in an app that will soon have five kinds.
Cost, honestly framed
Bucket create, head, list and presign are free and rate-limited. Writes bill at $0.0001 per call and body reads at $0.0002 per call, verified 26 July 2026; new accounts get $2 of free credit, which is around twenty thousand avatar writes before you spend anything. Get today’s numbers straight from the catalogue:
curl -sS -X GET "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 fees are not where an avatar bill goes anyway — stored bytes and egress are, and both are metered separately. Rates have moved down over time and discount campaigns run, so treat the figures above as an upper bound and check GET /v1/account/usage once you have real traffic. For a hundred thousand users with one 200 KB avatar each, you’re arguing about 20 GB of storage, and every option in that table is cheap at 20 GB.
Serve the image through a CDN or your own cache either way. Signing a URL on every page render is the single most common way to turn a free call into a rate-limit problem.