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 comes back as 5 MiB, and it’s worth knowing what kind of rule that is before you build against it. This host doesn’t enforce it — we completed an upload whose first part was 1 MiB on 27 July 2026 and got ok: true back. The floor is an S3-family convention, and the stores that do enforce it reject at assembly, after the entire file has already gone up the wire. So “just use 1 MB parts so retries are cheap” isn’t an error you’ll see here; it’s a trap that springs the day the same code points somewhere stricter. 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 27 July 2026, 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. Reads are the exception to the per-call shape: storage.object.get is metered on the size of the response body rather than the number of calls, which for a 4K render is the line that grows. 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. The first is about where the PUT originates: POST /v1/storage/bucket/set_cors/{bucket} accepts a rule set and bucket/get reads it back, but the storage host still answers a real browser preflight with a 403 carrying no Access-Control-* headers, so the paths above are server-side or native-client patterns and a page-to-bucket upload wants R2, S3 or Supabase Storage for that leg. Buy one of those if the browser tab has to hold the bytes; everything else here is unaffected, because a render coming out of a model is already on a server.

The second is scope. 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 this will feel manual next to it. The trade you make in return shows up on the invoice rather than in the API: the queue that ran the render, the POST /v1/errors/capture around the failed upload and the bytes themselves land on one bill, and GET /v1/account/usage breaks the whole render pipeline down by capability instead of asking you to reconcile three vendors’ PDFs at month end.

References

Browse more storage developer guides