Do avatar-sized uploads need multipart? The size threshold, measured
Multipart has a 5 MiB minimum part size, so small images can't benefit. Where the real threshold sits, with timings and the simpler upload that replaces it.
Short answer: no. A 240 KB avatar doesn’t need a multipart upload, and can’t really use one — the smallest part any S3-family service accepts is 5 MiB, so your file becomes a single part and you’ve paid three extra round trips for nothing. Upload it with one PUT. On Infrai that’s a presigned URL plus a plain HTTPS PUT, or a single JSON call if the file is tiny and you’d rather not manage a second request.
Multipart earns its complexity in one situation: the file is large enough that losing the connection near the end is genuinely expensive. Everything below is about finding where that line sits.
The rule of thumb
| File size | What to use | Why |
|---|---|---|
| under ~1 MB | PUT /v1/storage/object/put with base64 | one call, no second request to coordinate |
| ~1 MB to ~50 MB | presigned PUT straight to the storage host | no base64 inflation, no proxy in the middle |
| ~50 MB to 100 MB | presigned PUT, retry the whole file on failure | still cheaper than orchestration |
| over 100 MB | multipart | a failed part costs 5 MiB, not 500 MB |
AWS makes roughly the same recommendation in its own guidance — multipart for objects above 100 MB — and the threshold isn’t about the protocol so much as about what you’re willing to re-send.
Avatars, product thumbnails, icons, signature images: all comfortably in the first row.
Why 5 MiB decides it
Every part except the last must be at least 5 MiB. Open a multipart upload and the API tells you so directly:
{
"ok": true,
"data": {
"upload_id": "1785025928f6cc7fc0fd9695d37cd41e0a5717c99e2e6f967456c4d925541bab",
"bucket_id": "bkt_30ffaa0d9db6472fb060b3",
"key": "avatars/u_9002/original.png",
"part_size_min": 5242880,
"part_count_max": 10000
}
}
So a 240 KB file uploaded “in parts” is one part. It does complete successfully — the last part is exempt from the minimum — but the resumability you were buying doesn’t exist, because there’s nothing to resume to.
You can see the shape of the deal in the call count. A multipart upload of one small file is POST /v1/storage/multipart/create/{bucket}, then POST /v1/storage/multipart/presign_part/{upload_id}/{part_number}, then the PUT to the storage host, then POST /v1/storage/multipart/complete/{upload_id}. Four requests. A presigned single upload is two.
What that costs in wall-clock time
We pushed the same 240 KB PNG into a bucket three ways, three runs each, from one client on 2026-07-26. Medians:
| Method | HTTP requests | Median |
|---|---|---|
| base64 through the API | 1 | ~2.3 s |
| presign + direct PUT | 2 | ~1.0 s |
| multipart, single part | 4 | ~2.3 s |
Your numbers will differ — this is one machine on one connection, not a benchmark — but the ordering is structural. Multipart is slowest here because it’s three API round trips wrapped around the same single upload, and base64 is slow because the payload grows by a third and has to be buffered whole.
The avatar upload, start to finish
Ask for the upload slot:
export INFRAI_API_KEY="your_infrai_api_key"
curl -sS -X POST \
"https://api.infrai.cc/v1/storage/object/presign/app-avatars/avatars/u_8812/original.png" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"op": "put", "expires_seconds": 600}'
Send the bytes to the URL that comes back. Note there’s no Authorization header on this one — the signature in the query string is the credential:
UPLOAD_URL="$(curl -sS -X POST \
'https://api.infrai.cc/v1/storage/object/presign/app-avatars/avatars/u_8812/original.png' \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H 'Content-Type: application/json' \
-d '{"op": "put", "expires_seconds": 600}' | jq -r '.data.url')"
curl -sS -X PUT --data-binary @avatar.png -H 'Content-Type: image/png' "${UPLOAD_URL}"
In Node, with the check that actually matters — comparing stored size against sent size:
import { readFile } from "node:fs/promises";
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 = "app-avatars";
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(15000),
});
const json = await res.json();
if (!json.ok) throw new Error(`${json.error.code}: ${json.error.message}`);
return json.data;
}
export async function uploadAvatar(userId, file, contentType = "image/png") {
const bytes = await readFile(file);
if (bytes.length > 5 * 1024 * 1024) throw new Error("avatar too large; resize before upload");
const key = `avatars/${userId}/original.png`;
const slot = await api("POST", `/v1/storage/object/presign/${BUCKET}/${key}`, {
op: "put",
expires_seconds: 600,
});
const put = await fetch(slot.url, {
method: "PUT",
body: bytes,
headers: { "Content-Type": contentType },
signal: AbortSignal.timeout(30000),
});
if (!put.ok) throw new Error(`storage host returned ${put.status}`);
const meta = await api("GET", `/v1/storage/object/head/${BUCKET}/${key}`);
if (!meta.found || meta.size_bytes !== bytes.length) throw new Error("upload did not land intact");
return meta;
}
console.log(await uploadAvatar("u_8812", "./avatar.png"));
The verification step:
curl -sS "https://api.infrai.cc/v1/storage/object/head/app-avatars/avatars/u_8812/original.png" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"found": true,
"status": "found",
"key": "avatars/u_8812/original.png",
"size_bytes": 245760,
"etag": "ea4d298d3ea5d29e11ed6d489abe7daa",
"content_type": "image/png",
"last_modified": "2026-07-26T00:46:08Z"
}
}
Two things beginners hit next
Writing the same key twice replaces the object; there’s no versioning to fall back on, so “user re-uploads their avatar” is an overwrite and the old bytes are gone. If you want the previous one recoverable, put a short hash or a timestamp in the key and keep a pointer in your database.
And retries: for a small file, the correct retry is the whole upload again. Re-presign first, because a URL that expired during a stalled attempt will keep failing for a reason unrelated to the network.
Where something else fits better
If you’re already on the AWS SDK, @aws-sdk/lib-storage picks single-PUT or multipart for you based on size, and that’s a fair argument for staying with S3 when uploads are the only thing you need. Wasabi has written about the flip side — orphaned parts from abandoned multipart uploads keep costing storage until something cleans them up — which is another reason not to reach for multipart on files that don’t need it. Self-hosting MinIO makes sense if the bytes must stay on your own hardware.
The drawback of the Infrai route, honestly stated: browser-direct upload isn’t practical yet because there’s no route to set bucket CORS, so the file has to come through your server or a native client. If your avatar picker uploads straight from the browser tab today, keep that on S3 or R2 for now.
The cost side
storage.object.put is $0.0001 per call and storage.multipart.upload_part the same, with complete at $0.0002 — verified 2026-07-26, and new accounts get $2 of free credit. Presigning and head are free. So the four-request multipart path for an avatar costs about three times the two-request path and takes twice as long, which is the whole argument in one line.
Rates move, and downward more often than not, so check rather than trust:
curl -sS "https://api.infrai.cc/v1/discovery" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
| jq '[.capabilities[] | select(.id | startswith("storage.")) | {id, price: .billing.price_usd}]'
The structural facts outlive the digits: presigned uploads carry no per-call storage charge because the bytes never pass through the API, multipart multiplies your call count by the number of parts plus two, and stored bytes are billed separately from either.