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_dump grows 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

ApproachGood forReal costDrawback
Postgres byteaValues under ~8 KB, transactionalHighest per GB, paid again in every backup and replicaDumps and restores balloon; no tiering
Postgres large objectsLegacy streaming needsSame as aboveAwkward API, lo_unlink leaks, still in your backups
Amazon S3Anything, at any scaleCheap per GB, egress billedAnother account, IAM to model
Backblaze B2Cost-sensitive archivesAmong the cheapest per GBFewer regions; separate vendor
Supabase StorageYou already run SupabaseBundledOnly compelling inside that stack
Infrai storageDocuments beside the rest of your backendPer call, no separate accountMetered 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.

References

Browse more storage developer guides