Postgres blobs or object storage for user documents: the real cutoff
Where the break-even sits between bytea in Postgres and an S3-compatible bucket, what each costs to back up, and a Node 22 pattern that avoids orphans.
Keep the bytes in object storage and keep the facts about the bytes in Postgres. That split is cheaper on every axis that matters — per gigabyte, per backup, per restore, per connection held open — once documents are more than a few kilobytes. Infrai’s storage API is S3-compatible and lives on the same key as the rest of your backend, so the pointer row in your database and the object it points at don’t need two vendor accounts between them.
The interesting question isn’t whether to split. It’s where the cutoff sits, because below it a bytea column is genuinely the simpler answer and pretending otherwise is cargo-culting.
Where the cutoff actually is
Postgres stores a row inline until it exceeds roughly 2 KB, at which point TOAST compresses the wide column and, if that isn’t enough, moves it to a side table with its own I/O path. A bytea value can technically reach 1 GB. Neither number is the useful one.
The useful number is what a gigabyte of documents does to everything around the database:
pg_dumpgrows by the full size of every blob, so a 40 GB document corpus makes a 45 GB dump and a restore that takes hours instead of minutes.- Every byte is written to WAL, shipped to each replica, and stored again in every base backup — you pay for that gigabyte three or four times over.
- Reading a 20 MB file pins a connection from a pool sized for millisecond queries.
- Managed Postgres storage typically runs an order of magnitude above object storage per GB, and unlike a bucket you can’t tier it to something colder.
So: under about 8 KB, and genuinely transactional (a signature blob, a small avatar, a JSON attachment), bytea is fine and cheaper than the round trip. Above that, a bucket wins and keeps winning. Between 8 KB and a megabyte it’s a judgement call, and the deciding factor is usually backup size rather than read latency.
Documents — invoices, contracts, scans, exports — are almost never under 8 KB.
What the split looks like
The database holds identity, ownership and lifecycle. The bucket holds bytes.
CREATE TABLE documents (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id uuid NOT NULL,
object_key text NOT NULL UNIQUE,
filename text NOT NULL,
content_type text NOT NULL,
size_bytes bigint,
etag text,
state text NOT NULL DEFAULT 'pending',
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX documents_tenant_idx ON documents (tenant_id, created_at DESC);
state is the part people skip and then regret. There’s no transaction spanning Postgres and a bucket, so one of the two writes can fail alone. Insert the row as pending, upload, then flip it to stored — a row without an object is a harmless sweep candidate, whereas an object without a row is invisible garbage you’ll be paying for in a year.
Small documents, one call
For anything comfortably under a megabyte, PUT /v1/storage/object/put/{bucket}/{key} takes base64 in JSON and stores it server-side. No presign round trip.
import { readFile } from "node:fs/promises";
import { basename } from "node:path";
import pg from "pg";
const API = "https://api.infrai.cc";
const BUCKET = "tenant-docs";
const token = process.env.INFRAI_API_KEY;
if (!token) throw new Error("INFRAI_API_KEY is not set");
const db = new pg.Pool({ connectionString: process.env.DATABASE_URL });
export async function storeSmallDocument(tenantId, path, contentType) {
const bytes = await readFile(path);
if (bytes.length > 1_000_000) throw new Error("use the presigned path above 1 MB");
const key = `docs/${tenantId}/${Date.now()}-${basename(path)}`;
const { rows } = await db.query(
`INSERT INTO documents (tenant_id, object_key, filename, content_type, size_bytes)
VALUES ($1, $2, $3, $4, $5) RETURNING id`,
[tenantId, key, basename(path), contentType, bytes.length],
);
const payload = {
data_base64: bytes.toString("base64"),
content_type: contentType,
metadata: { tenant: tenantId, filename: basename(path) },
cache_control: "private, max-age=0",
};
const res = await fetch(`${API}/v1/storage/object/put/${BUCKET}/${key}`, {
method: "PUT",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error(`put failed: HTTP ${res.status} ${await res.text()}`);
const { data } = await res.json();
await db.query(`UPDATE documents SET state = 'stored', etag = $2 WHERE id = $1`, [rows[0].id, data.etag]);
return { id: rows[0].id, key, etag: data.etag };
}
Note the metadata object. Filenames belong there, not in the key — that keeps keys machine-shaped while the download still knows what to call the file.
Verify without paying for a download:
export INFRAI_API_KEY="your_infrai_api_key"
curl -sS "https://api.infrai.cc/v1/storage/object/head/tenant-docs/docs/acme/contract.pdf" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"found": true,
"status": "found",
"key": "docs/acme/contract.pdf",
"size_bytes": 184203,
"etag": "5d41402abc4b2a76b9719d911017c592",
"content_type": "application/pdf",
"metadata": { "tenant": "acme", "filename": "contract.pdf" }
}
}
Big documents skip your server entirely
Above a megabyte, base64 in JSON is the wrong shape — it inflates the payload by a third and puts the whole file through your process memory. Sign an upload slot instead and let the client push bytes straight at the bucket. Signing is free.
curl -sS -X POST "https://api.infrai.cc/v1/storage/object/presign/tenant-docs/docs/acme/2026-audit.pdf" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"op":"put","expires_seconds":600,"content_type":"application/pdf","max_bytes":52428800}'
The response carries url, method, headers and expires_at; send the raw bytes to that URL with the returned method and headers, and don’t attach your API key to it.
Measuring instead of estimating
Two free reads tell you what you’re actually holding and what you’re actually spending. GET /v1/storage/bucket/usage/{bucket} reports object count and bytes; GET /v1/account/usage breaks 30 days of spend down by capability.
curl -sS "https://api.infrai.cc/v1/storage/bucket/usage/tenant-docs" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
curl -sS "https://api.infrai.cc/v1/account/usage" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
The usage breakdown is per capability id, so storage.object.put shows up beside ai.chat and pdf.generate in one list — that’s the per-tenant cost question answered by a query rather than by reconciling four invoices.
The options, side by side
| Approach | Good for | Real cost | Drawback |
|---|---|---|---|
Postgres bytea | Values under ~8 KB, transactional | Highest per GB, paid again in every backup and replica | Dumps and restores balloon; no tiering |
| Postgres large objects | Legacy streaming needs | Same as above | Awkward API, lo_unlink leaks, still in your backups |
| Amazon S3 | Anything, at any scale | Cheap per GB, egress billed | Another account, IAM to model |
| Backblaze B2 | Cost-sensitive archives | Among the cheapest per GB | Fewer regions; separate vendor |
| Supabase Storage | You already run Supabase | Bundled | Only compelling inside that stack |
| Infrai storage | Documents beside the rest of your backend | Per call, no separate account | Metered egress; no read-time transformation |
What it costs, and how to re-check
Structure first, because that’s what survives a price change: bucket create, list, head, presign and lifecycle rules are free and rate-limited. Writes and reads are billable per call, with reads at roughly twice the write rate. Verified 25 July 2026, a write is $0.0001 per call and a read $0.0002 per call — and per-GB storage plus egress are metered separately, which is where STORAGE_BANDWIDTH_EXCEEDED comes from.
curl -sS "https://api.infrai.cc/v1/discovery" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
| python3 -c "import json,sys; [print(c['id'], c['billing'].get('price_usd', 'free')) for c in json.load(sys.stdin)['capabilities'] if c['id'].startswith('storage.object')]"
Those rates drift downward and campaigns run, so the figures above are the pessimistic end. New accounts get $2 of free credit, roughly twenty thousand writes.
One limitation to plan around: per-call pricing means ten thousand 4 KB files cost more than one 40 MB archive. If your documents are tiny and numerous, batch them into a container object — or leave them in Postgres, where they belonged.