Base64 in JSON or a presigned binary PUT: how to pick, with measurements

Two upload shapes, one decision. Round-trip counts, a 33% size penalty, measured latencies on Infrai, and the 403 that catches everyone on the presigned path.

Decide on three axes: where the bytes are, how big they are, and how many round trips you can afford. If the file already lives in your server process and it’s small, base64 inside a JSON body is one authenticated call and no state to track — that’s the simple path, and on Infrai it’s PUT /v1/storage/object/put/{bucket}/{key}. If the bytes start on someone else’s machine, or they’re large enough that a 33% encoding penalty stings, ask for a presigned URL and push raw binary at it.

That’s the textbook rule. Our measurements complicate it, so read on before you commit.

Base64 turns every 3 bytes into 4 characters, which is a hard 1.333× floor set by the encoding itself — a 200,000-byte file became a 266,728-byte JSON body when we measured it, ratio 1.334 including the field names. Encoding and decoding also cost CPU and, more importantly, memory: both ends hold the whole thing as a string. For a 40 KB CSV nobody cares. For a 40 MB video it’s a memory spike on a container you sized for 256 MB.

The two shapes side by side

Base64 in JSONPresigned binary PUT
Round trips12 (presign, then upload)
Bytes on the wire1.33× the file
Where bytes may originateyour server onlyanywhere that can reach the URL
Auth on the upload legyour API keythe signature in the URL
Client complexityJSON.stringifyecho the returned headers exactly
Server memorywhole file as a stringnone, if the client uploads

The row that decides most architectures is the third one. A presigned URL exists so that bytes never touch your server — that’s its entire reason for being. If the bytes are already in your process, you’ve paid the cost the presigned pattern is designed to avoid, and adding a second round trip to hand yourself a URL buys nothing.

Path A: one call, base64 body

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

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

const BUCKET = "kb-upload-choice-0726";

export async function uploadInline(key, filePath, contentType) {
  const bytes = await readFile(filePath);
  if (bytes.length > 1_000_000) {
    throw new Error(`${filePath} is ${bytes.length} bytes — use the presigned path`);
  }

  const payload = {};
  payload.data_base64 = bytes.toString("base64");
  payload.content_type = contentType;

  const res = await fetch(`https://api.infrai.cc/v1/storage/object/put/${BUCKET}/${key}`, {
    method: "PUT",
    headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" },
    body: JSON.stringify(payload),
  });
  const json = await res.json();
  if (!res.ok || json.ok !== true) {
    throw new Error(`inline upload failed: HTTP ${res.status} ${JSON.stringify(json.error ?? json)}`);
  }
  return json.data;
}

console.log(await uploadInline("uploads/invoice_2291.pdf", "invoice.pdf", "application/pdf"));

The guard clause matters. Without a size check this path degrades silently: it keeps working, slowly, until one customer uploads something big and your request handler dies holding a 60 MB string.

Path B: presign, then push binary

export INFRAI_API_KEY="your_infrai_api_key"

curl -sS -X POST "https://api.infrai.cc/v1/storage/object/presign/kb-upload-choice-0726/uploads/blob_200k.bin" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"op":"put","expires_seconds":600,"content_type":"application/octet-stream"}'
{
  "ok": true,
  "data": {
    "url": "https://infrai-1333115350.cos.ap-singapore.myqcloud.com/a4ee0c441fa36c267.kb-upload-choice-0726/uploads/blob_200k.bin?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=600&X-Amz-SignedHeaders=content-type%3Bhost&X-Amz-Signature=4635a4510e4cd08d9b468",
    "method": "PUT",
    "headers": { "Content-Type": "application/octet-stream" },
    "fields": null,
    "expires_at": "2026-07-26T01:15:31.437304Z",
    "max_bytes": null
  }
}

Use method and headers from that response rather than hard-coding them. Here’s the whole thing as a script, with the upload leg reading the file as a stream-friendly buffer and no JSON encoding anywhere:

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

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

const BUCKET = "kb-upload-choice-0726";

export async function uploadPresigned(key, filePath, contentType) {
  const signRes = await fetch(`https://api.infrai.cc/v1/storage/object/presign/${BUCKET}/${key}`, {
    method: "POST",
    headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" },
    body: JSON.stringify({ op: "put", expires_seconds: 600, content_type: contentType }),
  });
  const signed = await signRes.json();
  if (!signRes.ok || signed.ok !== true) {
    throw new Error(`presign failed: HTTP ${signRes.status} ${JSON.stringify(signed.error ?? signed)}`);
  }

  const body = await readFile(filePath);
  const putRes = await fetch(signed.data.url, {
    method: signed.data.method,
    headers: signed.data.headers,
    body,
  });
  if (!putRes.ok) {
    throw new Error(`vendor PUT rejected: HTTP ${putRes.status} ${await putRes.text()}`);
  }
  return { key, bytes: body.length, expires_at: signed.data.expires_at };
}

console.log(await uploadPresigned("uploads/blob_200k.bin", "blob.bin", "application/octet-stream"));

Three ways the presigned path bites

The signature covers the headers listed in X-Amz-SignedHeaders. Drop one and the vendor returns 403 with no useful explanation — we reproduced it in a second by sending the file without its Content-Type:

curl -sS -X PUT --upload-file blob.bin "${PRESIGNED_URL}" \
  -o /dev/null -w "http=%{http_code}\n"
# http=403 — Content-Type was in SignedHeaders and wasn't sent

curl -sS -X PUT --upload-file blob.bin \
  -H "Content-Type: application/octet-stream" "${PRESIGNED_URL}" \
  -o /dev/null -w "http=%{http_code}\n"
# http=200

Second: op takes get or put, and nothing else is validated the way you’d hope. Passing an invented value such as "upload" returns ok: true with a download URL — signed for host only, no content-type — so your uploader gets a link that will never accept a write and an error message that points at the wrong leg entirely. Spell it put.

Third: the TTL ceiling is 7 days. Ask for 604801 seconds and you get STORAGE_INVALID_TTL with the range spelled out, which is at least an honest error.

And the limitation that decides browser architectures: no route sets bucket CORS rules, so a browser fetch to one of these URLs dies in preflight. Server-to-server presigning works — the 200 above was a real upload — but browser-direct upload into an Infrai bucket can’t work today. If that’s your requirement, Cloudflare R2 or S3 with a CORS configuration is the right choice, and no amount of cleverness on this API substitutes for it.

What we measured

Against a bucket in eu-central-1, from a laptop, mid-2026:

  • Inline base64 object/put: 3.5–4.5 s per call, and about the same for an 8 KB file as for a 200 KB one — the fixed cost dominates.
  • object/presign: 130 ms–1.7 s.
  • Binary PUT of 200 KB to the returned URL: roughly 0.5 s.
  • object/head to confirm: about 70 ms.

So the two-call path finished in about a second while the “simpler” one-call path took three to four. That inverts the usual advice, and it’s the kind of thing worth re-measuring from your own region before you design around it — a single laptop on a single network isn’t a benchmark, it’s a smell test.

Confirm the object landed, either way

curl -sS -X GET "https://api.infrai.cc/v1/storage/object/head/kb-upload-choice-0726/uploads/blob_200k.bin" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"
{
  "ok": true,
  "data": {
    "found": true,
    "status": "found",
    "key": "uploads/blob_200k.bin",
    "size_bytes": 200000,
    "etag": "98902029d62700652fd3d9e331a30efb",
    "content_type": "application/octet-stream",
    "metadata": null,
    "last_modified": "2026-07-26T01:05:32Z"
  }
}

A cheap batch equivalent, when you want to audit a whole prefix after a bulk import rather than one key at a time:

curl -sS -X GET "https://api.infrai.cc/v1/storage/object/list/kb-upload-choice-0726?limit=5" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

This step is not optional on the presigned path. Your API never sees the upload leg, so found: true with the expected size_bytes is the only thing that tells you a client actually finished. A missing object returns found: false inside a 200 rather than a 404.

Billing, and how to re-check it

Verified 2026-07-26: object/put costs $0.0001 per call, object/get $0.0002 — reads run about twice writes — and object/presign, object/head and object/list are free, rate-limited, and don’t draw down the $2 credit a new account starts with. That means the presigned path is billed for zero API calls on Infrai’s side; you pay the vendor storage and egress by GB either way. Prices in this market trend downward and campaigns come and go, so pull today’s numbers instead of trusting a published table:

curl -sS "https://api.infrai.cc/v1/discovery" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  | python3 -c "import json,sys; d=json.load(sys.stdin); print([(c['id'], c['billing'].get('price_usd','free')) for c in d['capabilities'] if c['id'].startswith('storage.object.p')])"

The rule we’d actually apply

Under 1 MB, originating on your server, and you want one code path: inline base64, with a size guard. Anything larger, anything originating on a client, or anything where a memory spike would hurt: presign. Above about 100 MB, neither — use multipart, which exists precisely because a single PUT of that size retries badly.

If your infrastructure is already one AWS account, @aws-sdk/client-s3 gives you both shapes with IAM instead of a key, and staying there is reasonable. Supabase is worth a look when file access should be governed by the same row-level policies as your tables. What one Infrai key buys is that the upload, the queue that processes the file, the email confirming it and the error capture when a presigned PUT 403s all live on one account with one bill — which matters when storage is one of six services you need, and doesn’t when it’s the only one.

References

Browse more storage developer guides