Large-file multipart uploads: part size is what sets the price
Presigned parts versus proxied parts, the call-count arithmetic behind a 2 GB upload, and how Infrai, S3, R2, tus and UploadThing compare for EU startups.
For a multi-gigabyte upload the per-GB storage rate is almost never what you end up paying for. Two other things are: how many API calls your part size implies, and whether the bytes travel through somebody’s server on the way to the bucket. Infrai’s multipart surface makes both explicit — POST /v1/storage/multipart/presign_part/{upload_id}/{part_number} is free, and the part bytes then go straight to storage without a per-part charge.
That distinction is the whole cost story, and it’s worth doing the arithmetic before picking a provider. A 2 GB file split into 5 MiB parts is 410 parts; the same file in 64 MB parts is 32. Same bytes, an order of magnitude apart in call count.
The arithmetic
| File size | 5 MiB parts | 16 MB parts | 64 MB parts |
|---|---|---|---|
| 500 MB | 100 | 32 | 8 |
| 2 GB | 410 | 128 | 32 |
| 20 GB | 4,096 | 1,280 | 320 |
| 100 GB | 20,480 | 6,400 | 1,600 |
Bigger parts mean fewer calls and a cheaper run. They also mean a failed part wastes more transfer, and browsers hold each part in memory while it uploads — 64 MB parts from a phone on hotel wifi is a bad trade. Somewhere between 8 MB and 32 MB is where most uploaders land, and the API reports its own floor: part_size_min comes back as 5,242,880 bytes with part_count_max of 10,000.
Ten thousand parts is a real ceiling, not a formality. At the 5 MiB minimum that caps a single object at roughly 50 GB, so for anything larger you have to raise the part size rather than add parts.
Opening an upload
export INFRAI_API_KEY="your_infrai_api_key"
curl -sS -X POST "https://api.infrai.cc/v1/storage/multipart/create/kb-bigfile-0726" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"key":"uploads/tenant_42/dataset-2026-07-26.zip","content_type":"application/zip"}'
{
"ok": true,
"data": {
"upload_id": "1785025114aea60b9b02508c8823be404a302e2265e90329959ed092d813d29d",
"bucket_id": "bkt_370b5b2c59c34ca5a749ed",
"key": "uploads/tenant_42/dataset-2026-07-26.zip",
"started_at": "2026-07-26T00:18:34.236783Z",
"part_size_min": 5242880,
"part_count_max": 10000
}
}
Then one presign per part. The URL that comes back is an ordinary SigV4 PUT with a one-hour window:
curl -sS -X POST \
"https://api.infrai.cc/v1/storage/multipart/presign_part/${UPLOAD_ID}/1" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{}'
Two routes for the same bytes, two different bills
This is the fork that decides your cost, and it’s easy to miss reading the route list.
Send each part’s bytes to the presigned URL and no billable Infrai call happens for that part at all — presigning is free, and the transfer is between the client and the storage layer. Send the same part to PUT /v1/storage/multipart/upload_part/{upload_id}/{part_number} instead and every part is a billable call, because the bytes are moving through the API.
The proxied route earns its keep in two situations: a client that can’t do a raw PUT (some embedded and mobile HTTP stacks are surprisingly limited), and a network where you want one egress destination to whitelist. Otherwise, presign.
A concurrent uploader
import { open, stat } from "node:fs/promises";
const API = "https://api.infrai.cc";
const BUCKET = "kb-bigfile-0726";
const PART_SIZE = 16 * 1024 * 1024;
const CONCURRENCY = 4;
async function call(path, init = {}) {
const res = await fetch(`${API}${path}`, {
...init,
headers: {
Authorization: `Bearer ${process.env.INFRAI_API_KEY}`,
"Content-Type": "application/json",
...(init.headers ?? {}),
},
});
if (!res.ok) throw new Error(`${path} -> ${res.status}: ${await res.text()}`);
return (await res.json()).data;
}
async function sendPart(handle, uploadId, partNumber, offset, length) {
const buffer = Buffer.allocUnsafe(length);
await handle.read(buffer, 0, length, offset);
for (let attempt = 1; attempt <= 3; attempt++) {
const slot = await call(`/v1/storage/multipart/presign_part/${uploadId}/${partNumber}`, {
method: "POST",
body: JSON.stringify({}),
});
const res = await fetch(slot.url, { method: slot.method, body: buffer });
if (res.ok) return { part_number: partNumber, etag: res.headers.get("etag").replaceAll('"', "") };
if (attempt === 3) throw new Error(`part ${partNumber} failed: ${res.status}`);
await new Promise((r) => setTimeout(r, 500 * 2 ** attempt));
}
}
export async function upload(file, key) {
const { size } = await stat(file);
const handle = await open(file, "r");
const session = await call(`/v1/storage/multipart/create/${BUCKET}`, {
method: "POST",
body: JSON.stringify({ key, content_type: "application/zip" }),
});
const jobs = [];
for (let offset = 0, n = 1; offset < size; offset += PART_SIZE, n++) {
jobs.push({ n, offset, length: Math.min(PART_SIZE, size - offset) });
}
try {
const parts = [];
for (let i = 0; i < jobs.length; i += CONCURRENCY) {
const batch = jobs.slice(i, i + CONCURRENCY);
const done = await Promise.all(
batch.map((j) => sendPart(handle, session.upload_id, j.n, j.offset, j.length)),
);
parts.push(...done);
process.stdout.write(`\r${parts.length}/${jobs.length} parts`);
}
parts.sort((a, b) => a.part_number - b.part_number);
return await call(`/v1/storage/multipart/complete/${session.upload_id}`, {
method: "POST",
body: JSON.stringify({ parts }),
});
} catch (err) {
await call(`/v1/storage/multipart/abort/${session.upload_id}`, { method: "DELETE" }).catch(() => {});
throw err;
} finally {
await handle.close();
}
}
Retry inside the part, not around the whole upload — a re-presign is free and a re-uploaded 16 MB part is cheap, whereas restarting a 20 GB transfer because part 973 hit a TCP reset is how an upload feature gets a reputation.
Then confirm, for nothing:
curl -sS "https://api.infrai.cc/v1/storage/object/head/kb-bigfile-0726/uploads/tenant_42/dataset-2026-07-26.zip" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
Who can do this from a browser, honestly
| Option | Browser-direct multipart | Resumable across a page reload | Where the money goes |
|---|---|---|---|
| Infrai | Not today — rules can be stored, the preflight still fails | Yes, if you persist upload_id and part ETags yourself | Free presigns, one billable completion, reads metered per GB |
| AWS S3 | Yes, CORS is yours to configure | Yes, via ListParts | Per-request charges plus per-GB egress |
| Cloudflare R2 | Yes | Yes, S3-compatible multipart | Class A/B operations; zero egress |
| Backblaze B2 | Yes, S3-compatible endpoint | Yes | Cheap storage; free egress to Cloudflare |
| tus server | Yes, and it’s the protocol built for it | Yes, by design — offset-based resume | You run and pay for the server |
| UploadThing | Yes, managed | Partly | Per-GB pricing with a free tier; least code to write |
Read the first row as written, because the nuance matters. There is a route for the rules — POST /v1/storage/bucket/set_cors/{bucket} takes an allowed-origins rule set, returns 200, and GET /v1/storage/bucket/get/{bucket} reads the same rules straight back. What hasn’t landed is the storage host acting on them: an OPTIONS preflight against a presigned part URL, carrying a real Origin and Access-Control-Request-Method, answers 403 with no Access-Control-Allow-* header at all, so the browser stops before it ever sends your correctly signed PUT. Control plane yes, data plane not yet.
So for a browser upload widget today you’d be better off on R2 or S3, or on UploadThing if you want the whole thing handed to you. The multipart path here is the right shape for desktop agents, mobile apps, CLI tools and CI jobs, where CORS never enters the picture — and for a web app, a thin relay on your own server that forwards each part to the presigned URL.
What it costs, and reading today’s number
Structure first: multipart/create, presign_part and abort are free and rate-limited; completion and proxied part uploads are billable per call; a new account starts with $2 of credit. So the presigned path prices a 2 GB upload at a single billable call regardless of part count — verified 27 July 2026 at $0.0002 for the completion, against $0.0001 per part on the proxied route.
Getting the file back out is priced on a different axis, and it’s the one people mis-model. GET /v1/storage/object/get/{bucket}/{key} isn’t a per-call charge at all — it meters the bytes it actually returns, $0.104 per GB on the same reading. Uploading is priced by how you chop the file up; downloading is priced by how big it is. Which means a write-once, read-rarely archive and a write-once, read-constantly asset library have completely different bills off the same routes, and a request count tells you nothing about either.
curl -sS "https://api.infrai.cc/v1/discovery" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
| python3 -c "import json,sys; [print(c['id'], c['billing']['is_billable'], c['billing'].get('price_usd')) for c in json.load(sys.stdin)['capabilities'] if c['id'].startswith('storage.multipart')]"
Print the unit next to the figure when you run that, because the units differ between routes and a number without one is how a cost model goes wrong. Storage rates trend downward and discount campaigns run, so what you read today may well be lower than what’s printed here.
Buckets take a region at creation and it’s enforced now rather than merely recorded: ask for a region the vendor isn’t provisioned in and POST /v1/storage/bucket/create refuses with a 400 that names the region it does serve. That’s the honest way to settle a residency question — make the call, read what comes back, and don’t promise a customer a jurisdiction the API hasn’t confirmed to you in writing.
One more thing worth weighing against a single-purpose uploader. When the 2 GB file lands, the work that follows it is already on the same key: POST /v1/queue/publish to hand the object to a transcoder or a virus scan, POST /v1/errors/capture when part 973 dies on a TCP reset, GET /v1/account/usage to attribute the bytes to the tenant who sent them. No second account, no second SDK, no second invoice — which is the part a specialist upload service structurally can’t match, whatever its per-GB rate.