Skip to content

Node + Postgres deployment

The second backend runs the same server in a Node process over Postgres. Supporting it meant rewriting the engine so one core serves both a synchronous storage adapter (SQLite in a Durable Object) and an asynchronous one (Postgres): every operation is written once as a storage-agnostic generator, and each backend supplies the interpreter that runs it. The two backends are then held to the same behavior by the same conformance suites.

  • One process, many folders, one database. Every table carries a folder id, and a folder registry owns exactly one async server and storage adapter per folder, created on first use. A folder’s operations run one at a time through the async server’s FIFO; different folders’ statements interleave freely. On shutdown the registry drains every folder’s queue before the host closes the shared database.
  • Postgres storage via the async flavor of the storage adapter — the same snapshots, event logs, Yjs state, and blob catalog as the Durable Object’s SQLite, statement for statement.
  • WebSockets to clients through a Node event bus with the same per-connection subscription bookkeeping as the Durable Object’s, and the same frame cap.
  • Identity is the host’s. A resolvePrincipal option turns each incoming request — WebSocket upgrade and blob routes alike — into a principal, the role the Worker plays in front of the Cloudflare backend. What that principal may do inside the folder is engine-enforced, as everywhere: see Authorization.
  • Blobs in a filesystem or S3-compatible store. The blob handlers mount on the same HTTP server as the WebSocket upgrade, under the same principal resolver. The byte store is a directory beside the database by default, or any S3-compatible bucket (AWS S3, R2’s S3 API, MinIO) from environment variables. A timer replaces the Durable Object’s alarm for garbage collection: each tick asks the blob catalog which folders in the database have something to reclaim — every folder, whether or not the process has served it — and sweeps those, reclaiming abandoned uploads and unreferenced blobs once they are past the retention window. Folders with clean catalogs cost nothing, and a restart loses no garbage.
flowchart TB
  accTitle: One Node process, many folders
  accDescr {
    Browsers connect over WebSockets to one Node process. The host's principal
    resolver turns each request into a principal. Inside the process a folder
    registry holds one async datadata server per folder, each with its own
    Postgres storage adapter, all reading and writing one shared Postgres
    database whose tables are scoped by folder id. Blob bytes go over HTTP to
    the same process, which streams them to a filesystem directory or an
    S3-compatible bucket.
  }
  browsers["browsers<br/><small>WebSocket · HTTP</small>"]
  store[("filesystem / S3<br/><small>blob bytes</small>")]
  pg[("Postgres<br/><small>PGlite today · tables scoped by folder</small>")]

  subgraph node["one Node process"]
    direction TB
    resolver["principal resolver<br/><small>host-owned</small>"]
    registry["folder registry"]
    a["async server<br/>folder A"]
    b["async server<br/>folder B"]
    resolver --> registry
    registry --> a
    registry --> b
  end

  browsers --> resolver
  a --> pg
  b --> pg
  node <-->|"stream bytes"| store

The Postgres adapter defaults to the Durable Object’s size limits — 1.9 MB per document, per event, and per embedded Y.Doc — even though Postgres has no such row limit, and the limits are overridable per adapter. The default is an application contract: a document valid in one deployment is valid in every other that keeps it, so an exported stream imports across backends.

The Yjs write-behind takes the same approach on both adapters, retry budget included: a burst whose flush fails is retried with backoff before it is dropped, and a dropped burst is reported to the host, since it is data subscribers already saw.

A shared conformance suite — create, delete and restore, rebuild, export and import, purge, blobs — runs against every storage adapter. A document behaves the same whichever backend holds it, and an exported stream moves between them.

  • Placement and scaling are yours. A Durable Object is placed and woken by the platform; a Node process runs where you run it, and a folder’s throughput is bounded by the process it lives in rather than by a per-folder object.
  • No scale to zero. The process runs whether or not any of its folders is active, where an idle Durable Object costs nothing. Presence and subscriptions live in process memory, so a restart is a reconnect for every client — the same reconnect and replay path as a Durable Object eviction.
  • One database to operate. Backups, retention, and the missing compaction are one Postgres problem instead of thousands of SQLite files.