Uploading large AI-generated images: a size-based strategy in Node

Which upload path to use at 200 KB, 9 MiB and 200 MiB, a complete multipart run in Node 22, and the three sharp edges that silently corrupt an object.

Choose by file size and stop thinking about it. Small renders go in a single JSON request; anything from a few megabytes up gets a presigned URL and a direct PUT; genuinely large files — upscaled 8K frames, animation sequences, model checkpoints — go through multipart. Infrai exposes all three on the same key, so the branch is a size comparison in your worker rather than three integrations.

The part that costs people an afternoon isn’t picking the strategy. It’s that a botched multipart assembles into a plausible-looking object instead of failing.

The size bands, with numbers

Measured from a single client, median of three runs each, so treat them as ratios rather than promises:

File sizeStrategyWhat we measuredWhy
under ~1 MBPUT with a base64 body240 KB in ~2.3 sone call, no coordination
1–10 MBpresign, then PUT the bytes direct240 KB in ~1.0 s, 9 MiB in ~2.5 sskips base64 inflation and the double hop
10 MB – 5 GBmultipart, parts in parallel9 MiB base64 took 10.4 s; 30 MiB took ~59 sresumable, parallel, no giant request

The base64 route is the simplest thing that works and it degrades exactly the way you’d expect: the payload grows by a third, your process holds the whole buffer, and at 30 MiB you’re past most platform request timeouts. That’s the whole reason the bands exist.

A complete multipart run

Three routes do the work. POST /v1/storage/multipart/create/{bucket} opens the upload and tells you the minimum part size; POST /v1/storage/multipart/presign_part/{upload_id}/{part_number} mints a URL per part; POST /v1/storage/multipart/complete/{upload_id} assembles them from a manifest of part numbers and ETags.

export INFRAI_API_KEY="your_infrai_api_key"

curl -sS -X POST "https://api.infrai.cc/v1/storage/multipart/create/genimages-large" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"key":"renders/job_5521/upscaled-8k.png","content_type":"image/png"}'
{
  "ok": true,
  "data": {
    "upload_id": "1785027583bcd60444ff5112f084c05870f85665439152a2a6eb9edc1a999ea6",
    "bucket_id": "bkt_87115cb730de4ad2ac263e",
    "key": "renders/job_5521/upscaled-8k.png",
    "part_size_min": 5242880,
    "part_count_max": 10000
  }
}

part_size_min is 5 MiB and part_count_max is 10,000, which together set your ceiling. Here’s the whole flow in Node 22 — split, presign, upload in parallel, assemble, verify:

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 BASE = "https://api.infrai.cc";
const BUCKET = "genimages-large";
const PART_SIZE = 8 * 1024 * 1024;
const auth = { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" };

async function api(path, method, body) {
  const res = await fetch(`${BASE}${path}`, { method, headers: auth, body: body ? JSON.stringify(body) : "{}" });
  const json = await res.json();
  if (!json.ok) throw new Error(`${path} -> ${json.error?.code}: ${json.error?.message}`);
  return json.data;
}

async function uploadLarge(localPath, objectKey) {
  const bytes = await readFile(localPath);
  const started = await api(`/v1/storage/multipart/create/${BUCKET}`, "POST", {
    key: objectKey,
    content_type: "image/png",
  });
  const uploadId = started.upload_id;

  const chunks = [];
  for (let offset = 0; offset < bytes.length; offset += PART_SIZE) {
    chunks.push(bytes.subarray(offset, Math.min(offset + PART_SIZE, bytes.length)));
  }
  if (chunks.length > started.part_count_max) throw new Error("too many parts — raise PART_SIZE");

  try {
    const parts = await Promise.all(chunks.map(async (chunk, i) => {
      const partNumber = i + 1;
      const slot = await api(`/v1/storage/multipart/presign_part/${uploadId}/${partNumber}`, "POST", {});
      const put = await fetch(slot.url, { method: slot.method ?? "PUT", body: chunk });
      if (!put.ok) throw new Error(`part ${partNumber} PUT failed: ${put.status}`);
      const etag = (put.headers.get("etag") ?? "").replaceAll('"', "");
      if (!etag) throw new Error(`part ${partNumber} returned no ETag`);
      return { part_number: partNumber, etag };
    }));

    const object = await api(`/v1/storage/multipart/complete/${uploadId}`, "POST", { parts });
    if (object.size_bytes !== bytes.length) {
      throw new Error(`assembled ${object.size_bytes} bytes, expected ${bytes.length}`);
    }
    return object;
  } catch (err) {
    await fetch(`${BASE}/v1/storage/multipart/abort/${uploadId}`, { method: "DELETE", headers: auth });
    throw err;
  }
}

const stored = await uploadLarge(process.argv[2] ?? "./render.png", "renders/job_5521/upscaled-8k.png");
console.log(`${stored.key} · ${stored.size_bytes} bytes · etag ${stored.etag}`);

A successful assembly answers with a multi-part ETag — the -2 suffix is the part count, not a typo:

{
  "ok": true,
  "data": {
    "bucket_id": "bkt_87115cb730de4ad2ac263e",
    "key": "renders/job_5521/upscaled-8k.png",
    "size_bytes": 6291456,
    "etag": "fbd92274568349918b6fb0160bc84e5a-2",
    "content_type": "application/octet-stream"
  }
}

Three sharp edges

First: don’t send part bytes through PUT /v1/storage/multipart/upload_part/{upload_id}/{part_number}. In our testing every call to it — tiny payload or 5 MiB — came back 503 VENDOR_DOWN with MissingContentLength from the underlying store. The presigned-part path works, which is why the script above uses it exclusively.

Second, and this is the one that corrupts data quietly: complete doesn’t validate your manifest against what was uploaded. Post two parts when you uploaded four and you get 200 ok and an object containing the first two, with a perfectly reasonable ETag on it. Nothing anywhere tells you the file is short. The size assertion in the script is not defensive programming — it’s the only check there is.

Third, a wrong ETag in the manifest surfaces as 503 VENDOR_DOWN with retryable: true, which is misleading: it’s a client-side mistake and retrying won’t fix it. If you see that code after complete, compare part numbers and ETags rather than backing off.

Confirm every large upload independently, with a route that costs nothing:

curl -sS "https://api.infrai.cc/v1/storage/object/head/genimages-large/renders/job_5521/frame-001.png" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"
{
  "ok": true,
  "data": {
    "found": true,
    "status": "found",
    "key": "renders/job_5521/frame-001.png",
    "size_bytes": 10,
    "etag": "fbd92274568349918b6fb0160bc84e5a-2",
    "content_type": "image/png",
    "last_modified": "2026-07-26T01:01:03Z"
  }
}

Clean up what you abandon

A generation job that crashes mid-upload leaves parts behind. Aborting is free and idempotent, so wire it into your failure path — the catch block above already does:

curl -sS -X DELETE "https://api.infrai.cc/v1/storage/multipart/abort/{upload_id}" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

What each path costs

The structure is what to remember: opening a multipart upload and presigning parts are free, a single-shot write bills one call, and assembling a multipart object bills roughly twice a plain write. So a 200 MiB file split into 25 parts costs about the same as a handful of small uploads — the per-call side of storage is noise next to the GB-month rent. Numbers move, so read them:

curl -sS "https://api.infrai.cc/v1/discovery" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" | grep -o '"storage.multipart[^}]*}'

curl -sS "https://api.infrai.cc/v1/storage/bucket/usage/genimages-large" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

A new account starts with $2 free credit, and storage prices in this market keep sliding downward, so treat any figure as a ceiling.

When to use something else

The AWS SDK’s lib-storage uploader is more mature than anything you’ll write in an afternoon: it handles concurrency, retries per part, and progress events, and if you’re already on S3 you should keep it. Cloudflare R2 is the better target when the browser has to do the uploading, because you can set bucket CORS there and Infrai currently has no route that does — a real limitation for client-side flows.

What you get by keeping the upload here is the second question being free: the same key that stored the render also queued the job, wrote the thumbnail, recorded the error when part 7 died, and bills all of it to one account you can query per tenant.

References

Browse more storage developer guides