Uploading a 500 MB video from Node 22 in four multipart calls
The minimum correct multipart flow for a half-gigabyte video: open, sign each part, PUT the chunks, assemble — with the three rules code generators get wrong.
A 500 MB video sits right at the size where a single PUT stops being sensible — one dropped connection at 470 MB and you start over from zero. So you split it. On Infrai that’s four calls: open an upload, ask for a signed URL per part, PUT each chunk to its URL, then post the part list back to assemble the object. Nothing between those calls is stateful on your side except a list of {part_number, etag} pairs.
That flatness is the whole reason this shape is easy to generate correctly. There’s no session to hold, no SDK client to configure, no resumable-protocol handshake — a model that knows the four endpoints and the part-list format produces working code, and the parts that go wrong are three specific rules rather than a hundred lines of transport logic.
The three rules that break generated uploaders
Every part except the last should be at least 5 MiB, and it’s worth knowing exactly what kind of rule that is. The create response hands you the floor as part_size_min, but it is advisory here rather than enforced — we assembled an upload whose first part was 1 MiB on 27 July 2026 and complete came back ok: true with a two-part etag. The reason to honour it anyway is portability: the same generated client pointed at an S3-family store that does enforce the floor fails at assembly, after every byte has already crossed the wire. Code that reads the floor and respects it works everywhere; code that discovers this host is lenient stops working the day you move.
Parts must be listed in ascending part_number order at completion, and the ETag you send has to be the one the storage host returned for that exact chunk, quotes stripped. And the part count ceiling is 10,000, which for a 500 MB file is never the binding constraint — but it is the reason to pick a part size in the tens of megabytes rather than the hundreds of kilobytes a naive generator sometimes chooses.
With 32 MiB parts, a 500 MB video is 16 chunks. Small enough that a retry costs a few seconds; large enough that per-part overhead disappears.
Step 1: open the upload
export INFRAI_API_KEY="your_infrai_api_key"
curl -sS -X POST "https://api.infrai.cc/v1/storage/multipart/create/kb-video-0726" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"key":"uploads/2026/07/demo-clip.mp4","content_type":"video/mp4"}'
{
"ok": true,
"data": {
"upload_id": "1785026523c5b26ef5d88c39cd3645a12e0daefcc8e090528301ae4b3d29954d6be7864d59",
"bucket_id": "bkt_4eddb11b04084a1fb0d631",
"key": "uploads/2026/07/demo-clip.mp4",
"started_at": "2026-07-26T00:42:03.061743Z",
"part_size_min": 5242880,
"part_count_max": 10000
}
}
part_size_min and part_count_max come back on every create, so a generated client can read the floor instead of hard-coding 5 MiB and hoping.
Step 2: a signed URL per part
curl -sS -X POST "https://api.infrai.cc/v1/storage/multipart/presign_part/1785026523c5b26ef5d88c39cd3645a12e0daefcc8e090528301ae4b3d29954d6be7864d59/1" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{}'
The response carries a url, the method to use against it, any headers to copy, and an expires_at. Signing is free and unlimited enough that re-signing a part you’re about to retry is cheaper than caching the URL.
Two ways to move the bytes, and they bill differently. Push each chunk to its signed URL and your traffic never touches the Infrai API — PUT /v1/storage/multipart/upload_part/{upload_id}/{part_number} exists for the case where you’d rather send parts through the API itself, and that route is billable per part. For a server-side uploader the signed-URL path is the one to take.
Step 3: the uploader
Node 22, no dependencies, bounded concurrency so a half-gigabyte file doesn’t turn into a half-gigabyte of resident memory:
import { open, stat } from "node:fs/promises";
import { Buffer } from "node:buffer";
const API = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");
const BUCKET = "kb-video-0726";
const PART_SIZE = 32 * 1024 * 1024;
const CONCURRENCY = 4;
async function call(path, verb, payload) {
const res = await fetch(`${API}${path}`, {
method: verb,
headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" },
body: payload === undefined ? undefined : JSON.stringify(payload),
});
const json = await res.json();
if (!res.ok || json.ok === false) {
const e = json.error ?? {};
throw new Error(`${verb} ${path} -> HTTP ${res.status} ${e.code ?? ""} ${e.message ?? ""}`);
}
return json.data;
}
async function putPart(uploadId, partNumber, chunk) {
const slot = await call(`/v1/storage/multipart/presign_part/${uploadId}/${partNumber}`, "POST", {});
for (let attempt = 1; attempt <= 4; attempt += 1) {
const res = await fetch(slot.url, { method: slot.method ?? "PUT", body: chunk });
if (res.ok) {
const etag = (res.headers.get("etag") ?? "").replaceAll('"', "");
if (etag) return { part_number: partNumber, etag };
}
await new Promise((r) => setTimeout(r, 400 * 2 ** attempt));
}
throw new Error(`part ${partNumber} failed after 4 attempts`);
}
export async function uploadVideo(filePath, objectKey) {
const { size } = await stat(filePath);
const partCount = Math.ceil(size / PART_SIZE);
if (partCount > 10000) throw new Error(`${partCount} parts exceeds the 10000 ceiling`);
const upload = await call(`/v1/storage/multipart/create/${BUCKET}`, "POST", {
key: objectKey,
content_type: "video/mp4",
});
const fh = await open(filePath, "r");
const parts = [];
let cursor = 1;
try {
const worker = async () => {
for (;;) {
const n = cursor;
cursor += 1;
if (n > partCount) return;
const offset = (n - 1) * PART_SIZE;
const length = Math.min(PART_SIZE, size - offset);
const buf = Buffer.allocUnsafe(length);
await fh.read(buf, 0, length, offset);
parts.push(await putPart(upload.upload_id, n, buf));
}
};
await Promise.all(Array.from({ length: CONCURRENCY }, worker));
} catch (err) {
await call(`/v1/storage/multipart/abort/${upload.upload_id}`, "DELETE").catch(() => {});
throw err;
} finally {
await fh.close();
}
parts.sort((a, b) => a.part_number - b.part_number);
return call(`/v1/storage/multipart/complete/${upload.upload_id}`, "POST", { parts });
}
const [, , file, objectKey] = process.argv;
if (!file || !objectKey) {
console.error("usage: node upload-video.mjs <file> <object-key>");
process.exit(1);
}
const done = await uploadVideo(file, objectKey);
console.log(`stored ${done.key} — ${done.size_bytes} bytes, etag ${done.etag}`);
Four workers holding 32 MiB each is a 128 MiB ceiling on buffers, which is the number to tune if you’re on a small container. Drop CONCURRENCY to 2 on a 512 MB instance.
Step 4: assemble, then check
curl -sS -X POST "https://api.infrai.cc/v1/storage/multipart/complete/1785026523c5b26ef5d88c39cd3645a12e0daefcc8e090528301ae4b3d29954d6be7864d59" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"parts":[{"part_number":1,"etag":"1fb99ab19e4345dce81e45d6342021e9"},{"part_number":2,"etag":"93c9a456a2333c1f887d9b3b06d74783"}]}'
Then confirm the object is really there, at the size you expect:
curl -sS "https://api.infrai.cc/v1/storage/object/head/kb-video-0726/uploads/2026/07/demo-clip.mp4" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"found": true,
"status": "found",
"key": "uploads/2026/07/demo-clip.mp4",
"size_bytes": 12582912,
"etag": "cc2cf8676bb84a6bfc0a55913d102b13-2",
"content_type": "video/mp4",
"metadata": null,
"last_modified": "2026-07-26T00:42:03Z"
}
}
Notice the -2 on the end of that ETag. A multipart object’s ETag is a digest of the part digests plus the part count, not an MD5 of the file, so don’t build an integrity check that compares it to a local hash — it will never match and it’ll page you at 3am.
What the four calls cost
| Call | Billing |
|---|---|
POST /v1/storage/multipart/create/{bucket} | free, rate-limited |
POST /v1/storage/multipart/presign_part/{upload_id}/{part_number} | free, rate-limited |
PUT /v1/storage/multipart/upload_part/{upload_id}/{part_number} | $0.0001 per part, only if you route bytes through the API |
POST /v1/storage/multipart/complete/{upload_id} | $0.0002 per call |
DELETE /v1/storage/multipart/abort/{upload_id} | free |
Verified 27 July 2026. A 500 MB video pushed through signed URLs therefore costs one billed assembly and sixteen free signings — the upload is close to free and its price barely moves with the file’s size.
Playback is the opposite shape, and it’s the half that decides a video product’s bill. GET /v1/storage/object/get/{bucket}/{key} isn’t a per-call charge at all: it meters the bytes it returns, at $0.104 per GB on the same reading. So uploading a 500 MB clip is a rounding error and serving it a thousand times is not, which is the arithmetic behind every “transcode to a smaller rendition and put a CDN in front” recommendation you’ve read. New accounts start with $2 of credit. Read today’s rates, and the units, rather than trusting this table:
curl -sS "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'), c['billing'].get('unit')) for c in json.load(sys.stdin)['capabilities'] if c['id'].startswith('storage.')]"
Rates in this market keep drifting down and discount campaigns run, so treat the numbers above as a ceiling.
Where you’d be better off elsewhere
The honest limitation: this flow assumes your server holds the bytes. A browser can’t push parts to an Infrai bucket today — POST /v1/storage/bucket/set_cors/{bucket} stores an origin list and hands it back on read, but the storage host answers a real OPTIONS preflight with 403 and no Access-Control-Allow-* header, and the browser stops there. If your 500 MB video has to go straight from an <input type="file"> to storage without transiting your box, buy R2 or S3 for that bucket; both apply the bucket’s CORS configuration at the object host, which is exactly what the preflight needs.
| Approach | Best when | Cost of being wrong |
|---|---|---|
Single PUT /v1/storage/object/put/{bucket}/{key} | files under ~100 MB | a failure at 90% restarts the whole transfer |
| Multipart via signed URLs (this article) | server-held files 100 MB–5 TB | you own the part bookkeeping |
Multipart via upload_part | you want every byte audited through one API | billed per part, and your server is in the data path twice |
| R2 or S3 browser-direct | the browser is the source of the bytes | a second vendor account, key rotation and invoice |
One more caveat worth flagging: a signed GET is an expiry mechanism, not an identity check. The object itself is private — drop the query string and the host answers 403 — but the URL is a bearer token for as long as it lives, so a link pasted into a chat is a working link. Keep TTLs short for a video someone paid for, and make the authorisation decision in the route that mints the URL.
MinIO is the other honest answer if you’re already running your own hardware and the bytes must not leave it — same S3-compatible multipart semantics, none of the per-call billing, all of the operational work. What Infrai buys you instead is the step after this one: queue.publish to kick off the transcode, email.send to tell the uploader it’s ready and errors.capture when ffmpeg dies are the same key and the same bill, with no second account and no second invoice for the parts of the workflow that aren’t storage.