Skip to content

Data

PostgreSQL

The relational database we default to on conventional server builds, for data models with real constraints and joins.

PostgreSQL is a database server: a long-running process you connect to, with strict types, transactional DDL, a mature query planner and a large library of extensions. It is the sensible default for anything relational, and the argument for using something else is almost always about where the code runs or what the thing costs when idle, rather than about the database being wrong.

How we use it

ClassProfile keeps everything in Neon's serverless Postgres, reached from a Cloudflare Worker API. The data model earns the database: tenants, memberships with owner, admin and member roles, invitations, profiles carrying structured work history and education entries rather than a blob of text, and a visibility level on every profile and post, which is a constraint problem more than a storage one and is explained in four visibility levels beat a page of toggles. Queries go through Drizzle so the schema and the TypeScript types stay in step.

Starterflare is the conventional side of the same shape: Postgres 16 under a NestJS backend, about 45 models in a Prisma schema, and continuous integration that stands up a real Postgres 16 container for every test run rather than mocking the database.

What it costs you

Connections are the first thing to bite. Postgres allocates a backend process per connection, so a few hundred is a lot, and serverless front ends create far more than that. Every serious deployment ends up behind a pooler, whether that is PgBouncer, Hyperdrive or a driver that speaks HTTP. Poolers have their own rules: in transaction pooling mode you lose session state, advisory locks held across statements, LISTEN and NOTIFY, and prepared statements need care. Neon's HTTP driver is one query per request, so a multi-statement transaction means switching to the pooled driver for that path.

Scale-to-zero has a cold start. The first query after an idle period waits for the compute to come back, which is fine for a dashboard and not fine for a health check that times out at 500ms.

Then there is the operational tail: autovacuum and table bloat, an ALTER TABLE that takes an ACCESS EXCLUSIVE lock and stalls the application while it rewrites, an index build that needs CONCURRENTLY in production and will not run inside a transaction if you use it. None of that is hard, but somebody has to know it.

The bill is the last one. A Postgres instance costs money while nobody is using the product, and if it arrives with a cache, a broker and a search cluster beside it the floor gets high fast. We put actual numbers on what a Postgres, Redis, broker and search stack costs at rest rather than guessing.

When we would choose something else

For a product that lives on Workers and has a storage problem rather than a query problem, SQLite and D1 removes the server, the pooler and the idle bill in one move, at the price of a 10 GB ceiling per database and no interactive transactions. When we want Postgres but not the surrounding build, Supabase supplies the database, authentication, storage and an API over the tables, and we accept the coupling that comes with it. We keep MySQL running where a Laravel application already assumes it, but it is not what we would start a new build on.

Where we have used it

Every build below lists this in its stack, so the claim is checkable.