Fixing upload timeouts for large AI-generated PNG and WebP renders

Why a 4K render times out on its way to object storage, measured numbers for each upload path, and the Node 22 code that stops it happening.

A model returns a 4096×4096 PNG, your worker pushes it at object storage inside a JSON request, and somewhere around 20 seconds the socket dies. The fix is almost always the same: stop sending image bytes through an API that expects JSON, and hand them to the storage host directly with a presigned PUT. On Infrai that’s one call to get the URL and one plain HTTPS PUT to use it — and for renders past a few tens of megabytes, a multipart upload so a dead connection costs you one part instead of the whole file.

Below are the three upload paths, what each one actually cost in wall-clock time when we measured them on 2026-07-26, and where each one breaks.

Where the seconds go

Base64 is the first problem. Encoding inflates the payload by about 33%, so a 9 MiB PNG becomes roughly 12 MiB of JSON string, and both ends have to hold the whole thing in memory before anything is written. The second problem is that every hop in between — your framework’s body parser, a reverse proxy, a serverless platform’s request budget — gets to enforce its own limit on that single request.

Same 9 MiB file, same machine, same afternoon:

PathWall clockBytes on the wireWhere it breaks
PUT /v1/storage/object/put with base64~10.4 s~12.6 MiBproxy body limits, serverless timeouts
Presigned PUT direct to storage~2.5 s9 MiBa dropped connection restarts the whole file
Multipart, 5 MiB partsparts in parallel9 MiBmore calls to orchestrate

The same base64 path with a 30 MiB render took about 59 seconds. That’s a single sample from one client, not a benchmark — but it’s enough to explain why the timeout shows up on big renders and never on avatars, and why the docs say base64 uploads aren’t recommended above 1 MB.

Path one: presign, then PUT the raw bytes

Ask for an upload URL. op is put here (get is the download direction), and the URL is short-lived by design:

export INFRAI_API_KEY="your_infrai_api_key"

curl -sS -X POST \
  "https://api.infrai.cc/v1/storage/object/presign/ai-image-uploads/originals/2026-07/render-8f2a.png" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"op": "put", "expires_seconds": 900}'
{
  "ok": true,
  "data": {
    "url": "https://infrai-1333115350.cos.ap-singapore.myqcloud.com/a4ee0c441fa36c267.ai-image-uploads/originals/2026-07/render-8f2a.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=900&X-Amz-Signature=76dbd1886b4383a93d4cebd6a6a82bbb6c037721e38b1e2bdee0ab7f27c3bee9",
    "expires_at": "2026-07-26T00:40:39.122228Z"
  }
}

Then the upload itself never touches the API:

UPLOAD_URL="$(curl -sS -X POST \
  'https://api.infrai.cc/v1/storage/object/presign/ai-image-uploads/originals/2026-07/render-8f2a.png' \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H 'Content-Type: application/json' \
  -d '{"op": "put", "expires_seconds": 900}' | jq -r '.data.url')"

curl -sS -X PUT --data-binary @render-8f2a.png \
  -H 'Content-Type: image/png' \
  "${UPLOAD_URL}"

One thing about WebP that costs people an afternoon: send the Content-Type header on that PUT. In our testing the storage host also infers a sane type from the key extension — a .webp key came back as image/webp even with no header — but relying on that is fragile, and an object stored as application/octet-stream will download instead of render in a browser.

The Node 22 version, with a timeout you chose

The default fetch behaviour is to wait forever, which is how a hung upload becomes a stuck worker. Set the deadline yourself, retry once on a transport error, then confirm with a HEAD-style check rather than trusting the 200.

import { readFile } from "node:fs/promises";
import { setTimeout as sleep } from "node:timers/promises";

const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");

const API = "https://api.infrai.cc";
const BUCKET = "ai-image-uploads";
const auth = { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" };

async function api(method, path, body) {
  const res = await fetch(`${API}${path}`, {
    method,
    headers: auth,
    body: body === undefined ? undefined : JSON.stringify(body),
    signal: AbortSignal.timeout(20000),
  });
  const json = await res.json();
  if (!json.ok) throw new Error(`${json.error.code}: ${json.error.message}`);
  return json.data;
}

async function uploadRender(localPath, key, contentType, uploadTimeoutMs = 120000) {
  const bytes = await readFile(localPath);

  for (let attempt = 1; attempt <= 2; attempt++) {
    const slot = await api("POST", `/v1/storage/object/presign/${BUCKET}/${key}`, {
      op: "put",
      expires_seconds: 900,
    });
    try {
      const put = await fetch(slot.url, {
        method: "PUT",
        body: bytes,
        headers: { "Content-Type": contentType },
        signal: AbortSignal.timeout(uploadTimeoutMs),
      });
      if (!put.ok) throw new Error(`storage host returned ${put.status}`);
      break;
    } catch (err) {
      if (attempt === 2) throw err;
      await sleep(1000 * attempt);
    }
  }

  const meta = await api("GET", `/v1/storage/object/head/${BUCKET}/${key}`);
  if (!meta.found || meta.size_bytes !== bytes.length) {
    throw new Error(`upload incomplete: stored ${meta.size_bytes} of ${bytes.length}`);
  }
  return meta;
}

console.log(await uploadRender("./render-8f2a.png", "originals/2026-07/render-8f2a.png", "image/png"));

Re-presigning inside the retry loop is deliberate. A URL that expired while the first attempt was stalling will fail again for a reason that has nothing to do with the network, and a fresh signature costs nothing.

Path two: multipart, when one request is too much to lose

Past roughly 50 MB — think a batch of upscaled frames or a PSD-sized export — a single PUT is a bad bet, because a drop at 90% costs you everything. Open a multipart upload instead:

curl -sS -X POST "https://api.infrai.cc/v1/storage/multipart/create/ai-image-uploads" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"key": "originals/2026-07/batch-9f31.png", "content_type": "image/png"}'
{
  "ok": true,
  "data": {
    "upload_id": "17850259216aebb8e4068727d230d9670cb30e507fccc352a46d6b45674d50b8",
    "bucket_id": "bkt_34d37e697c684cd180180f",
    "key": "originals/2026-07/batch-9f31.png",
    "started_at": "2026-07-26T00:32:01.964187Z",
    "part_size_min": 5242880,
    "part_count_max": 10000
  }
}

part_size_min is 5 MiB and only the last part may be smaller — that’s an S3-family rule, not an Infrai one, and it’s why “just use 1 MB parts so retries are cheap” doesn’t work. Each part gets its own presigned URL from POST /v1/storage/multipart/presign_part/{upload_id}/{part_number}, you PUT the slice to it, keep the ETag the storage host returns, and hand the collected list to POST /v1/storage/multipart/complete/{upload_id}. Three or four parts in flight at once is usually where throughput stops improving.

Confirm the bytes, don’t assume them

curl -sS \
  "https://api.infrai.cc/v1/storage/object/head/ai-image-uploads/originals/2026-07/render-8f2a.png" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"
{
  "ok": true,
  "data": {
    "found": true,
    "status": "found",
    "key": "originals/2026-07/render-8f2a.png",
    "size_bytes": 9437184,
    "etag": "7d66c15c832b94c4feaf4f638e1626b0",
    "content_type": "image/png",
    "last_modified": "2026-07-26T00:39:15Z"
  }
}

Compare size_bytes with what you sent. A truncated upload is the failure mode that hurts most, because it produces a file that exists, downloads, and renders as a grey band halfway down.

Costs, and the limits worth knowing

Presigning and the head check are free and rate-limited; storage.object.put is $0.0001 per call and storage.multipart.upload_part the same, with complete at $0.0002 — verified 2026-07-26, against $2 of free credit on a new account. Direct-to-host uploads skip the per-call charge entirely, so the cheap path and the fast path are the same path. Rates drift downward over time, so read them live:

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

Two honest caveats. Browser-side direct upload to an Infrai bucket isn’t practical today because there’s no route to set bucket CORS, so treat these calls as server-side or native-client patterns; if the upload has to start in a browser tab, S3 or Cloudflare R2 with a CORS rule is the right tool. And if what you actually want is a render pipeline — automatic WebP and AVIF variants, on-the-fly resizing, a CDN in front — Cloudinary does that as a product, and Infrai’s storage plus its own image routes will feel more manual by comparison. What you get in exchange is that the queue that ran the render, the error capture around it and the bill for all of it sit behind the one key.

References

Browse more storage developer guides