Multipart upload of a large AI-generated PNG from Node 22

Create, presign each part, PUT the bytes, complete — and abort when the render fails. A working Node uploader against Infrai's S3-compatible storage.

A 4K render out of an image model lands somewhere between 8 MB and 60 MB, and that’s the size range where a single request stops being a good idea. Infrai’s storage module speaks the S3 multipart vocabulary — an upload session, numbered parts, ETags collected on the way, one commit at the end — over four plain REST calls, so a Node worker can stream a render into a bucket without an AWS SDK anywhere in the dependency tree.

The short version: POST /v1/storage/multipart/create/{bucket} opens a session, each part gets a signed slot, your worker PUTs the raw bytes straight at that slot, and POST /v1/storage/multipart/complete/{upload_id} stitches them. If the render pipeline dies halfway, abort — otherwise the parts sit there costing you money.

The four calls, and which ones you pay for

StepRouteBilledGives you
Open a sessionPOST /v1/storage/multipart/create/{bucket}Freeupload_id, part_size_min, part_count_max
Get a slot for part NPOST /v1/storage/multipart/presign_part/{upload_id}/{part_number}FreeA pre-signed url you PUT bytes to
CommitPOST /v1/storage/multipart/complete/{upload_id}$0.0002 per callFinal key, size_bytes, multipart etag
Give upDELETE /v1/storage/multipart/abort/{upload_id}Free{"aborted": true}

Opening the session tells you the rules of the vendor behind the bucket:

export INFRAI_API_KEY="your_infrai_api_key"

curl -sS -X POST \
  "https://api.infrai.cc/v1/storage/multipart/create/kb-genimg-mp-0726" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"key":"renders/job_9001/full.png","content_type":"image/png"}'
{
  "ok": true,
  "data": {
    "upload_id": "1785027330484f0a...",
    "bucket_id": "bkt_53cf589d12034286b1cab8",
    "key": "renders/job_9001/full.png",
    "started_at": "2026-07-26T00:55:30.884177Z",
    "part_size_min": 5242880,
    "part_count_max": 10000
  }
}

5 MiB minimum per part except the last one, 10,000 parts maximum. Pick 8 MiB and stop thinking about it — a 60 MB render becomes eight parts, well inside every limit, and a retry costs one slice rather than the whole file. Part sizing is the only tuning knob here that has any real effect on wall-clock time, and the honest advice is that anything between 8 and 16 MiB behaves about the same on a normal cloud network; below the floor the vendor rejects the part outright, and above 64 MiB you’re back to the problem multipart was invented to solve.

The uploader

One file, Node 22, no SDK.

It reads the render, walks it in slices, signs each slice, PUTs the raw bytes at the signed URL, and collects the ETag the vendor hands back. The try block matters as much as the happy path: any failure aborts the session instead of leaving orphaned parts behind.

import { readFile } from "node:fs/promises";

const API = "https://api.infrai.cc";
const BUCKET = "kb-genimg-mp-0726";
const KEY = "renders/job_9001/full.png";
const PART_SIZE = 8 * 1024 * 1024;

const token = process.env.INFRAI_API_KEY;
if (!token) throw new Error("INFRAI_API_KEY is not set");
const headers = { Authorization: `Bearer ${token}`, "Content-Type": "application/json" };

const bytes = await readFile(process.argv[2] ?? "./render.png");

const opened = await fetch(`${API}/v1/storage/multipart/create/${BUCKET}`, {
  method: "POST",
  headers,
  body: JSON.stringify({ key: KEY, content_type: "image/png" }),
});
const session = (await opened.json()).data;
if (!session?.upload_id) throw new Error("could not open a multipart session");
console.log(`session open, part floor ${session.part_size_min} bytes`);

const parts = [];
try {
  for (let n = 1, offset = 0; offset < bytes.length; n++, offset += PART_SIZE) {
    const chunk = bytes.subarray(offset, offset + PART_SIZE);

    const signed = await fetch(
      `${API}/v1/storage/multipart/presign_part/${session.upload_id}/${n}`,
      { method: "POST", headers, body: "{}" },
    );
    const slot = (await signed.json()).data;
    if (!slot?.url) throw new Error(`no slot for part ${n}`);

    const sent = await fetch(slot.url, { method: "PUT", body: chunk });
    if (!sent.ok) throw new Error(`part ${n} rejected with HTTP ${sent.status}`);

    parts.push({ part_number: n, etag: (sent.headers.get("etag") ?? "").replaceAll('"', "") });
    console.log(`part ${n} done, ${chunk.length} bytes`);
  }

  const committed = await fetch(`${API}/v1/storage/multipart/complete/${session.upload_id}`, {
    method: "POST",
    headers,
    body: JSON.stringify({ parts }),
  });
  const result = await committed.json();
  if (!committed.ok || result.ok === false) throw new Error(result?.error?.code ?? `HTTP ${committed.status}`);
  console.log(`stored ${result.data.key}, ${result.data.size_bytes} bytes, etag ${result.data.etag}`);
} catch (err) {
  await fetch(`${API}/v1/storage/multipart/abort/${session.upload_id}`, { method: "DELETE", headers });
  console.error(`upload aborted: ${err.message}`);
  process.exitCode = 1;
}

Note where the bytes actually travel: to the vendor host in the signed URL, not through Infrai’s API.

That detail decides your memory profile. Your key never leaves the worker, the render is never base64-encoded, and the request carrying 8 MiB is a plain PUT with no JSON wrapper — which is why this pattern holds on a 512 MB container while a naive “read it all, encode it, post it” version falls over.

There is a server-side variant, PUT /v1/storage/multipart/upload_part/{upload_id}/{part_number}, that takes base64 bytes in a JSON body. In our testing on 26 July 2026 it returned VENDOR_DOWN with a MissingContentLength complaint from the upstream vendor — worth flagging, because it’s documented and looks like the obvious choice.

What a successful commit returns

{
  "ok": true,
  "data": {
    "bucket_id": "bkt_53cf589d12034286b1cab8",
    "key": "renders/job_9001/full.png",
    "size_bytes": 5246976,
    "etag": "324144686e6c1298efdcd6061b0078da-2",
    "content_type": "image/png",
    "last_modified": "2026-07-26T00:55:30Z"
  }
}

The -2 suffix on the ETag is the part count, which is how S3-compatible stores signal “this was assembled, don’t compare me to an MD5 of the whole file”. Any integrity check you write has to account for that.

Aborting, and what happens if you don’t

curl -sS -X DELETE \
  "https://api.infrai.cc/v1/storage/multipart/abort/${UPLOAD_ID}" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"
{ "ok": true, "data": { "aborted": true } }

Aborting twice returns HTTP 409 with STORAGE_MULTIPART_INCONSISTENT — the session is already gone, which is a fine thing to swallow in a cleanup job. Committing with an ETag that doesn’t match a stored part gets you a 503 wrapping the vendor’s InvalidPart, and it’s marked retryable even though retrying an incorrect part list never helps. Re-upload the part, then commit.

Abandoned sessions are the real trap.

AWS documents the same hazard for AbortMultipartUpload, and the fix travels: a job that walks sessions older than 24 hours and aborts them.

Confirm the object, then price it

curl -sS \
  "https://api.infrai.cc/v1/storage/object/head/kb-genimg-mp-0726/renders/job_9001/full.png" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

Verified 26 July 2026: only the commit is billable, at $0.0002 per call, while opening a session, signing parts and aborting are free and rate-limited. A new account carries $2 in credit. So a 60 MB render costs one commit no matter how many parts it took — the pricing pressure is on stored bytes, not on the number of slices. Pull today’s figures straight from the catalogue:

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')) for c in json.load(sys.stdin)['capabilities'] if 'multipart' in c['id']]"

Storage rates tend to fall and promotional pricing comes and goes, so read that rather than trusting this paragraph. The steadier argument: the key that committed this upload also runs the queue which scheduled the render, on one invoice.

When to use the AWS SDK instead

If your renders already live in Amazon S3 and you need checksums, storage classes or object lock, @aws-sdk/lib-storage does adaptive part sizing and parallelism this 60-line script doesn’t. Cloudflare R2 is the other honest answer when egress dominates the bill, since it doesn’t charge for it. The trade-off both ways is account sprawl — a second vendor, a second key rotation, a second invoice.

References

Browse more storage developer guides