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 JSON | Presigned binary PUT | |
|---|---|---|
| Round trips | 1 | 2 (presign, then upload) |
| Bytes on the wire | 1.33× the file | 1× |
| Where bytes may originate | your server only | anywhere that can reach the URL |
| Auth on the upload leg | your API key | the signature in the URL |
| Client complexity | JSON.stringify | echo the returned headers exactly |
| Server memory | whole file as a string | none, 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
PUTof 200 KB to the returned URL: roughly 0.5 s. object/headto 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.