Multipart image upload plus a sharp thumbnail pipeline in Node 22

Push a 40 MB original to private S3-compatible storage in parts, derive webp renditions with sharp, and store them beside it. Runnable Infrai calls, real failure modes.

A 40 MB camera original shouldn’t travel as a base64 string inside a JSON request. Split it into parts, sign each part, PUT them straight at the storage host, then tell the API to assemble them. Infrai’s multipart routes do exactly that — POST /v1/storage/multipart/create/{bucket} opens the upload, POST /v1/storage/multipart/presign_part/{upload_id}/{part_number} hands you a signed slot per chunk, and POST /v1/storage/multipart/complete/{upload_id} stitches the object together.

The derivatives are a separate concern, and sharp handles them locally in a few milliseconds per size. The interesting engineering is in the seams: which chunk size, what happens when part 7 of 9 fails, and how the completed object can be wrong while every call returned 200.

Open the upload

The create call names the key and content type up front, and tells you the constraints it will enforce.

export INFRAI_API_KEY="your_infrai_api_key"

curl -sS -X POST "https://api.infrai.cc/v1/storage/multipart/create/kb-photoraw-0726" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"key": "originals/2026/07/shoot-9002.jpg", "content_type": "image/jpeg"}'
{
  "ok": true,
  "data": {
    "upload_id": "178502786562c7d4d500e987ee12530bec16d07c0c5a726a4e928ad8bc5375470a16145549",
    "bucket_id": "bkt_642ed6e37af447b597f167",
    "key": "originals/2026/07/shoot-9002.jpg",
    "started_at": "2026-07-26T01:04:25.325458Z",
    "part_size_min": 5242880,
    "part_count_max": 10000
  }
}

5 MiB minimum per part except the last, 10,000 parts maximum. In our testing the minimum isn’t actually rejected — a run of 1 MiB parts completed fine — but building on that is asking for trouble the day it starts being enforced.

Sign a slot, then PUT the bytes at the storage host

UPLOAD_ID="178502786562c7d4d500e987ee12530bec16d07c0c5a726a4e928ad8bc5375470a16145549"

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 '{}'

You get back a SigV4 URL valid for one hour, the verb to use, and any headers to echo:

{
  "ok": true,
  "data": {
    "url": "https://infrai-1333115350.cos.ap-singapore.myqcloud.com/a4ee0c441fa36c267.kb-photoraw-0726/originals/2026/07/shoot-9002.jpg?uploadId=17850278656&partNumber=1&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=3600&X-Amz-Signature=1dfe7a6048b7a58d21e8eeb7e836b4ea44399fd0640d221c0d2480476d025e30",
    "method": "PUT",
    "headers": null,
    "expires_at": "2026-07-26T02:03:56.783684Z"
  }
}

The part bytes never touch the Infrai API. That’s the whole point — your process streams a chunk to the object host and keeps one small ETag string in memory.

The uploader

Node 22, no SDK. Each part is read straight off disk at its offset, so memory stays at one chunk regardless of file size, and the ETag from every response goes into the manifest slot for that part number.

import { open, stat } from "node:fs/promises";

const BASE = "https://api.infrai.cc";
const BUCKET = "kb-photoraw-0726";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");
const PART = 8 * 1024 * 1024;

async function api(method, path, payload) {
  const res = await fetch(`${BASE}${path}`, {
    method,
    headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" },
    body: payload === undefined ? undefined : JSON.stringify(payload),
    signal: AbortSignal.timeout(60000),
  });
  const json = await res.json();
  if (!json.ok) throw new Error(`${json.error.code}: ${json.error.message}`);
  return json.data;
}

async function sendPart(uploadId, number, chunk) {
  const slot = await api("POST", `/v1/storage/multipart/presign_part/${uploadId}/${number}`, {});
  const res = await fetch(slot.url, { method: slot.method ?? "PUT", body: chunk });
  if (!res.ok) throw new Error(`part ${number} rejected with HTTP ${res.status}`);
  const etag = res.headers.get("etag");
  if (!etag) throw new Error(`part ${number} returned no ETag`);
  return { part_number: number, etag };
}

export async function uploadOriginal(localPath, key) {
  const { size } = await stat(localPath);
  const total = Math.ceil(size / PART);
  const { upload_id } = await api("POST", `/v1/storage/multipart/create/${BUCKET}`, {
    key,
    content_type: "image/jpeg",
  });
  const fh = await open(localPath, "r");
  try {
    const parts = new Array(total);
    for (let n = 1; n <= total; n++) {
      const buf = Buffer.alloc(Math.min(PART, size - (n - 1) * PART));
      await fh.read(buf, 0, buf.length, (n - 1) * PART);
      parts[n - 1] = await sendPart(upload_id, n, buf);
    }
    const missing = parts.findIndex((p) => !p || !p.etag);
    if (missing !== -1) throw new Error(`manifest is missing part ${missing + 1} of ${total}`);
    return await api("POST", `/v1/storage/multipart/complete/${upload_id}`, { parts });
  } catch (err) {
    await api("DELETE", `/v1/storage/multipart/abort/${upload_id}`);
    throw err;
  } finally {
    await fh.close();
  }
}

console.log(await uploadOriginal("./shoot-9002.jpg", "originals/2026/07/shoot-9002.jpg"));

That parts.length !== total guard looks redundant. It isn’t, and here’s why.

Complete assembles what you list, not what arrived

We uploaded a 5 MiB part 1 and then completed with a manifest naming only part 1. The response was a cheerful 200 and an object of exactly 5,242,880 bytes with the etag 7e473147988c43456d6cdb3b5ea06d5d-1. Nothing compared the manifest against what the storage host actually held.

curl -sS -X POST "https://api.infrai.cc/v1/storage/multipart/complete/${UPLOAD_ID}" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"parts": [{"part_number": 1, "etag": "ba8c3fac0e224c9b79a8e74bebd54654"}]}'

So a swallowed error on part 7 doesn’t produce a failed upload — it produces a truncated JPEG that decodes to a grey band halfway down. Count your parts client-side before you complete, and abort with DELETE /v1/storage/multipart/abort/{upload_id} when the count is short. The catch is that a mismatched ETag surfaces as a 503 marked retryable, so a naive retry-on-5xx loop will spin on what is really a permanent client-side error. Check the message text before retrying.

Derive the renditions

sharp reads the local original once and writes three sizes. Do it before you throw the buffer away — re-downloading the original to make a thumbnail is a needless byte charge.

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

const BASE = "https://api.infrai.cc";
const BUCKET = "kb-photoraw-0726";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");

const SIZES = [{ w: 1600, name: "w1600" }, { w: 640, name: "w640" }, { w: 200, name: "thumb" }];

export async function makeRenditions(localPath, slug) {
  const source = await readFile(localPath);
  const out = [];
  for (const size of SIZES) {
    const body = await sharp(source)
      .rotate()
      .resize({ width: size.w, withoutEnlargement: true })
      .webp({ quality: 82 })
      .toBuffer();
    out.push({ key: `derived/2026/07/${slug}/${size.name}.webp`, body, type: "image/webp" });
  }
  return out;
}

export async function storeRenditions(renditions) {
  for (const r of renditions) {
    const payload = { data_base64: r.body.toString("base64"), content_type: r.type };
    const res = await fetch(`${BASE}/v1/storage/object/put/${BUCKET}/${r.key}`, {
      method: "PUT",
      headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" },
      body: JSON.stringify(payload),
    });
    const json = await res.json();
    if (!json.ok) throw new Error(`${r.key}: ${json.error.code}`);
    console.log(r.key, json.data.size_bytes, "bytes");
  }
}

.rotate() with no argument applies the EXIF orientation, which is the difference between correct thumbnails and a gallery of sideways phone photos. Derivatives are small enough that base64 in a JSON body is fine — a 200 px webp is a few kilobytes.

Check the result

curl -sS \
  "https://api.infrai.cc/v1/storage/object/head/kb-photoraw-0726/originals/2026/07/shoot-9002.jpg" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

An etag ending in -1 tells you the object was assembled from a single part. If you uploaded nine, that suffix is your corruption alarm.

Local sharp or a transformation service?

Infrai storage + sharpS3 + sharp on LambdaCloudflare R2 + WorkersCloudinary
Who resizesyour process, ~30 ms per sizeLambda, cold starts includedWorker at the edgethe vendor, on request
Renditions decidedat uploadat uploadon demandon demand, by URL
Storage cost of variantsyou pay for eachyou pay for eachyou pay for eachincluded
Works offline / on your boxyesnonono
Same credential also runsqueues, email, cron, AIS3 onlyR2 onlymedia only

If your product needs arbitrary crops chosen by the front end at request time, a transformation CDN like Cloudinary genuinely wins and you’d be better off buying it. Fixed rendition sets — avatars, gallery tiles, OG images — are cheaper and more predictable done once at upload.

What the pipeline costs

The metadata calls are free: create, presign_part and abort don’t bill. Assembling bills once at $0.0002 per storage.multipart.complete, and each rendition write bills $0.0001, both verified 2026-07-26 against the live meter. New accounts get $2 of credit, which covers roughly ten thousand completed uploads before anything is charged.

curl -sS "https://api.infrai.cc/v1/discovery" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  | jq '.capabilities[] | select(.id | startswith("storage.multipart")) | {id, billing}'

Rates fall over time and discount campaigns run, so run that command rather than trusting the figures above. The shape that survives any repricing: signing is free, assembly and writes are metered per call, and the bytes you push directly to the storage host don’t pass through the API at all.

References

Browse more storage developer guides