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 size5 MiB parts16 MB parts64 MB parts
500 MB100328
2 GB41012832
20 GB4,0961,280320
100 GB20,4806,4001,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

OptionBrowser-direct multipartResumable across a page reloadWhere the money goes
InfraiNot today — no route to set bucket CORS rulesYes, if you persist upload_id and part ETags yourselfFree presigns, one billable completion, metered bandwidth
AWS S3Yes, CORS is yours to configureYes, via ListPartsPer-request charges plus per-GB egress
Cloudflare R2YesYes, S3-compatible multipartClass A/B operations; zero egress
Backblaze B2Yes, S3-compatible endpointYesCheap storage; free egress to Cloudflare
tus serverYes, and it’s the protocol built for itYes, by design — offset-based resumeYou run and pay for the server
UploadThingYes, managedPartlyPer-GB pricing with a free tier; least code to write

Read the first row as written. The Infrai storage API reports a bucket’s cors_rules but has no route to set them, so a cross-origin PUT from a web page will fail its preflight no matter how correct the signature is. 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 simply doesn’t apply.

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 26 July 2026 at $0.0002 for the completion, against $0.0001 per part on the proxied route.

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')]"

Storage rates trend downward and discount campaigns run, so what you read today may well be lower than what’s printed here. Buckets take EU region codes — eu-central-1, eu-west-1 — at creation time, though it’s worth checking the hostname in a presign response before you make a residency promise to a customer.

References

Browse more storage developer guides