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 sizeWhat to useWhy
under ~1 MBPUT /v1/storage/object/put with base64one call, no second request to coordinate
~1 MB to ~50 MBpresigned PUT straight to the storage hostno base64 inflation, no proxy in the middle
~50 MB to 100 MBpresigned PUT, retry the whole file on failurestill cheaper than orchestration
over 100 MBmultiparta 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:

MethodHTTP requestsMedian
base64 through the API1~2.3 s
presign + direct PUT2~1.0 s
multipart, single part4~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.

References

Browse more storage developer guides