DEV Community

OwenSullivan9135
OwenSullivan9135

Posted on

Cheapest Node.js Backend for Uploaded Files: PostgreSQL Bytes or Object Storage?

Short answer: To store user-uploaded documents in a Node.js backend, keep bounded small files and metadata in one database transaction; when large-file traffic and recovery need independence, separate relational metadata from object storage.

Storing document bytes in a relational database is reasonable when files are small, access follows the same authorization path as ordinary rows, and operational simplicity matters more than isolating file traffic. For most growing upload systems, searchable metadata and ownership state can remain relational while immutable bytes move to object storage. The cheapest backend is the one whose backup, restore, deletion, and failure behavior the team can actually operate; a low storage rate does not settle that question.

This is an architecture decision, not a vendor comparison. The deciding evidence should come from the workload: file-size distribution, upload concurrency, download traffic, retention rules, recovery objectives, and the people who will be on call. I don't trust a cost estimate that leaves database backups and restore time outside the spreadsheet.

Should a Node.js backend store user-uploaded documents in PostgreSQL or object storage?

Choose between two coherent designs rather than smearing responsibility across both. A database-blob design stores document bytes, metadata, ownership, and lifecycle state in one transactional system. A split design stores metadata and lifecycle state in PostgreSQL, then addresses the bytes by an opaque object key in an S3-compatible store. In either design, the application remains the authorization boundary; a guessed identifier must never become permission to read a document.

The split design is my default once uploads can materially affect the database's ordinary query, backup, or recovery workload. That is a judgment about failure containment — not a claim that object storage is always faster or cheaper. A compact internal tool with tightly bounded files may be better served by one PostgreSQL deployment because it has fewer moving pieces. An archive that mostly serves large documents has a different shape and usually benefits from separating byte transfer from relational queries.

Before choosing, write down the invariants:

  • A document is not visible until both durable bytes and committed metadata exist.
  • Every visible metadata row resolves to exactly one intended object or blob.
  • Authorization is evaluated against current ownership and policy, not against possession of a storage key.
  • A delete request makes the document unreachable through the application, then drives physical deletion through a retryable process.
  • Backups, restores, and disaster-recovery tests cover metadata and bytes as one logical dataset.

The fourth invariant deserves care. GDPR Article 17 describes a right to erasure in applicable circumstances, while also listing exceptions; architecture cannot decide whether a request is legally valid, but it can make an approved deletion traceable and executable. Keep deletion state in the metadata model, retain only the audit information your policy permits, and test the physical cleanup path instead of assuming that removing a row removed every copy.

One more boundary is easy to miss: HTTP caching. A private document response can be copied into browser, intermediary, or shared caches depending on how the response is delivered. MDN documents Cache-Control directives and their response semantics. Select directives from the document's sensitivity and delivery model, and verify the actual response headers at the edge. Storage placement alone does not control caching.

Name the failure boundaries before comparing cost

The apparent simplicity of a blob column hides coupling. Large writes share database connections, transaction machinery, replication, backup, and restore operations with metadata queries. That coupling may be acceptable — even desirable — when the bounded workload makes one atomic transaction the strongest operational advantage. It becomes a liability when document traffic and relational traffic need independent capacity, recovery, or deployment decisions.

Object storage changes the failure shape. The application now coordinates two systems without a cross-system transaction, so upload succeeded, metadata commit failed is a normal state to design for. The inverse state is worse: publishing metadata before durable bytes exist creates a document users can see but cannot retrieve. Avoid it by staging bytes under an opaque key, verifying completion, committing metadata, and only then changing application-visible state. An asynchronous sweeper can delete staged objects that never acquired committed metadata.

Be precise here.

Retries can also duplicate work. Generate the document ID and object key before the first write, attach an idempotency key to the application command, and make repeated attempts converge on the same metadata state. A cleanup worker should be safe to run twice. If a process dies after the byte write but before the row commit, the staged object remains invisible and later becomes eligible for cleanup; if it dies after the commit, the committed row remains the source of truth.

The exact timeout and cleanup interval cannot be universal. I'm not sure what either should be without the upload-size tail, client retry behavior, and an observed distribution of completion times. Measure those three inputs, choose a margin, and alert on the age and count of staged objects rather than treating the interval as folklore.

Decision axis PostgreSQL bytes Database metadata plus object bytes
Atomicity Bytes and metadata can commit in one database transaction Requires an explicit staged-to-visible protocol
Failure containment File and relational workloads share a failure and recovery boundary Byte traffic and relational queries can be isolated
Backup and restore One system, but document volume is included in database recovery work Two datasets must be reconciled and tested together
Authorization Naturally routed through the application and row policy Application must map an opaque key to current policy before delivery
Operational burden Fewer components for a bounded workload More lifecycle jobs, metrics, and reconciliation logic
Best fit Small, bounded documents where transactional simplicity wins Growing or large-file workloads needing independent byte handling

Do not put a currency value in this table until the team has measured the full path. Count primary storage, replicas, backups, retained versions, retrieval and transfer, database I/O, cleanup operations, and engineering time. Your mileage may vary because retention and download patterns often dominate a headline capacity rate.

Make the critical path boring and observable

The following Python sketch shows the protocol, even if the production service exposing it is Node.js. The interfaces are deliberately generic: the important part is the order and the state transitions, not a client library.

from dataclasses import dataclass
from typing import BinaryIO, Protocol


class ObjectStore(Protocol):
    def put(self, key: str, body: BinaryIO) -> None: ...
    def delete(self, key: str) -> None: ...


class Documents(Protocol):
    def reserve(self, document_id: str, owner_id: str, object_key: str) -> None: ...
    def mark_visible(self, document_id: str) -> None: ...
    def mark_deleting(self, document_id: str) -> None: ...
    def mark_deleted(self, document_id: str) -> None: ...


@dataclass(frozen=True)
class UploadCommand:
    document_id: str
    owner_id: str
    object_key: str
    body: BinaryIO


def store_document(
    command: UploadCommand, objects: ObjectStore, documents: Documents
) -> None:
    # Repeating reserve or put must converge on the same document and key.
    documents.reserve(
        command.document_id, command.owner_id, command.object_key
    )
    objects.put(command.object_key, command.body)
    documents.mark_visible(command.document_id)


def erase_document(
    document_id: str, object_key: str, objects: ObjectStore, documents: Documents
) -> None:
    documents.mark_deleting(document_id)
    objects.delete(object_key)
    documents.mark_deleted(document_id)
Enter fullscreen mode Exit fullscreen mode

The upload handler should validate declared size and type before accepting work, stream rather than buffer an entire large file in application memory, compute an integrity value while reading, and apply limits at every ingress that can receive the body. Those are application concerns regardless of where the final bytes live. Treat a client filename as display metadata, never as a filesystem path or storage key.

The model needs at least a stable document ID, owner or tenant ID, opaque storage locator, size, media type, integrity value, lifecycle state, and timestamps required by policy. Keep the locator opaque because it is infrastructure, not a public identity. A download request should resolve the stable ID, evaluate authorization, reject non-visible lifecycle states, and only then arrange byte delivery.

Observability should follow the state machine. Track upload attempts, committed documents, staged-object age, cleanup attempts, delete age, integrity failures, byte volume, latency by size band, and authorization denials. Reconciliation is equally important: periodically compare visible metadata with addressed bytes and staged bytes with reservations, then produce a bounded repair queue. Don't let the reconciler silently mutate ambiguous records; ambiguity should become an operator-visible case with enough identifiers to investigate.

Deploy the state transitions before enabling cleanup. During a migration, write new documents through one authoritative path, backfill old bytes with recorded integrity values, verify reads, and switch traffic in a reversible stage. The dangerous moment is not copying bytes; it is allowing two paths to disagree about which copy is authoritative.

Record the rejected option and when to reverse the decision

For a service expecting unbounded user uploads, I would reject PostgreSQL blob storage as the default because it couples byte growth and transfer to the relational system's backup and recovery boundary. The catch is that the split design buys isolation by accepting coordination work: staging, cleanup, reconciliation, two-system disaster recovery, and more operational signals. It is not suitable when the team cannot own those processes or when the workload is so small and fixed that they create more risk than they remove.

Stick with database bytes for a bounded internal application when one transaction, one access-control path, and one restore procedure are the stronger requirements. Revisit that choice when measured document volume changes restore tests, file transfers contend with ordinary queries, or independent retention and scaling become necessary. Conversely, stick with the split design only while the team proves that metadata and object recovery remain consistent. Architecture decisions expire when their evidence does.

The final decision record should contain the chosen invariants, rejected alternative, workload assumptions, cost model inputs, recovery objectives, deletion procedure, cache policy, migration trigger, and a review date. Then test the unpleasant sequences: process termination between stages, duplicate requests, partial client uploads, authorization changes during a download attempt, deletion retries, metadata restore without current objects, and object restore without current metadata. A design that passes a unit test but has no answer for those sequences is unfinished.

Choose from evidence. The database-versus-object question is secondary to maintaining a truthful state machine across upload, access, recovery, and erasure.

References

Further reading

Top comments (0)