DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Two Postgres clients, one database, and the only option they agree on is the one the pooler forces

PubTrivia is two deployments against one Supabase Postgres. There is a Next.js app on Vercel, which is everything you can see at pub-trivia.app, and there is a long-running WebSocket process on Railway that keeps a live quiz night in sync. Both of them talk to the same database with the same library, postgres-js behind Drizzle.

They do not share a client. There are two files, 80 lines and 40 lines, and almost every option in one is the opposite of the same option in the other. That is not drift. It is the one thing in our infrastructure where the same dependency, pointed at the same database, genuinely wants two different configurations, and writing them down side by side is the clearest explanation of serverless I have in the repository.

The option they agree on

prepare: false,
Enter fullscreen mode Exit fullscreen mode

Both files set it. Both files carry a comment saying why, and the comments say the same thing in different words: Supabase's transaction pooler, Supavisor on port 6543, does not support prepared statements. In transaction mode your connection is handed to whichever backend is free for the duration of a statement, so a prepared statement created on one backend is not there on the next one. postgres-js prepares by default, so you turn it off, in both halves, forever.

That is the only line the two files would agree on if you diffed them for intent.

The serverless client is tuned to not survive

Here is the Vercel half, trimmed to the options:

postgres(process.env.DATABASE_URL!, {
  prepare: false,
  max: isProduction ? 1 : 5,
  connect_timeout: 15,
  idle_timeout: isProduction ? 0 : 30,
  max_lifetime: isProduction ? 60 * 5 : 60 * 30,
  fetch_types: false,
  onnotice: isProduction ? undefined : console.log,
})
Enter fullscreen mode Exit fullscreen mode

Every production value there is chosen to make a connection short-lived on purpose.

max: 1 because a pool is a thing you keep, and a serverless function instance is a thing that gets suspended mid-life and woken up later. A pool of five open sockets in a frozen instance is five sockets the pooler is holding a slot for and nobody is using.

idle_timeout: 0 is the load-bearing one. Zero means close the connection the moment it stops being used. The failure it prevents is the one that reads, in a bug report, as "it works and then it breaks after a couple of pages": the instance comes back, the socket it was holding has been dropped at the other end or by something in between, and the next query fails immediately rather than reconnecting. If the connection is already gone before the freeze, there is nothing to go stale.

max_lifetime: 60 * 5 is the belt to that braces. Five minutes, after which a connection is rotated even if it has never been idle long enough for the timeout to fire.

connect_timeout: 15 goes the other way, and it is the one option on the serverless side tuned for patience rather than impatience. A cold Vercel invocation plus a TLS handshake to Supabase can take four to six seconds, and the cost of a 15 second ceiling is that a genuinely broken database takes 15 seconds to tell you so. The cost of a two second ceiling is a cold start that fails for no reason.

fetch_types: false saves a round trip. postgres-js normally issues a Describe on first connect to learn type OIDs. On a client whose whole life might be one query, that introspection is a meaningful share of the total.

Then the part that only exists in development:

declare global {
  var __db_client: postgres.Sql | undefined;
}

const client = global.__db_client ?? createClient();
if (!isProduction) global.__db_client = client;
Enter fullscreen mode Exit fullscreen mode

The globalThis stash is there for hot reload, not for production. In development Next re-evaluates modules on every edit, and without the global you get a new five-connection pool per save until Supabase stops answering. In production the condition never writes, because globalThis is empty on a cold start anyway, so the branch is a comment with syntax.

The long-running client is tuned to survive

The Railway half, same library, whole file is 40 lines:

postgres(process.env.DATABASE_URL!, {
  prepare: false,
  max: 10,
  idle_timeout: 30,
  max_lifetime: 60 * 30,
  connect_timeout: 5,
})
Enter fullscreen mode Exit fullscreen mode
option Vercel (production) Railway
max 1 10
idle_timeout 0 30
max_lifetime 300 1800
connect_timeout 15 5
fetch_types false default
globalThis singleton development only none

Every difference comes from one fact: this process is not suspended between requests. It boots once, holds its pool, and is still the same process an hour later. So a pool is worth having, 30 seconds of idle is cheap, half an hour of lifetime is a rotation policy rather than a safety measure, and five seconds is plenty to connect because there is no cold start to absorb.

The asymmetry I find hardest to defend is connect_timeout. Fifteen against five is a 3x difference in how long each half waits for the same database, and only one of the two files explains its number. The Railway side just says 5.

One connection serving 21 modules, ten serving one

The count runs the other way from the pool sizes.

On the Next side, 21 files import that client: the Stripe webhook, the checkout route, the auth bootstrap, and every dashboard action that reads or writes a venue. All of them share a single production connection, one at a time, per function instance.

On the WebSocket side, exactly one module imports the client, src/game/queries.ts, and it gets ten connections. That is not generous, it is bursty: a running session asks for several reads at once when it has something to tell a room, and ten is the headroom for the burst without saturating a pooler shared with the other half of the app.

The warning both files repeat

Both files end with the same note, and it is the only duplicated comment in the repository I have deliberately left duplicated:

// Do NOT enable transform: postgres.camel.
Enter fullscreen mode Exit fullscreen mode

postgres.camel rewrites snake_case keys to camelCase, which sounds like exactly what a TypeScript codebase wants. The problem is that it does not stop at column names. It reaches into the contents of jsonb columns, so a configuration object stored as JSON comes back with every one of its keys renamed, and the code that wrote scoring_mode reads undefined while scoringMode sits there holding the value. Drizzle already aliases columns through its own select() API, so the transform buys nothing and costs a class of bug that only shows up in whichever feature keeps its settings in JSON.

Two files, one warning, written twice because whoever adds the third client will read one of them and not the other.

How little of the site reaches either client

This is the part worth checking rather than believing. The marketing site mostly does not have a database at all.

$ curl -s https://pub-trivia.app/sitemap.xml | grep -c '<loc>'
72
$ curl -sI https://pub-trivia.app/features/multi-venue | grep -iE 'x-nextjs-prerender|^age'
x-nextjs-prerender: 1
age: 350713
Enter fullscreen mode Exit fullscreen mode

Seventy of those 72 URLs answer with x-nextjs-prerender: 1 and an age in the hundreds of thousands of seconds, which is to say they were built once and have been served from the CDN for days. The guides, the venue pages, the feature pages and the free tools are all in that set. None of them open a connection. The only two per-request pages are the homepage and /pricing, which are per-request for a currency reason rather than a data reason, and you can see that in their headers:

$ curl -sI https://pub-trivia.app/pricing | grep -i cache-control
cache-control: private, no-cache, no-store, max-age=0, must-revalidate
Enter fullscreen mode Exit fullscreen mode

So the one-connection-per-instance client, with its 15 second patience and its zero second idle timeout, is a client for signed-in traffic. Hosts in a dashboard, a Stripe webhook arriving, an invite being accepted. The thousands of page views that do not need Postgres never ask it for anything, and that is the only reason a pool size of one is a sane number to ship.

What I would change

Share a factory. Two files calling postgres() with a profile argument, serverless or long-running, would put the comparison table above into the code instead of into a blog post, and would stop the next option from being added to one side only.

Give the Railway client its own reasons in writing. Four of its five numbers are defensible and none of them say why they were picked, which is how a value that was right for one deployment survives into the next one unexamined.

And decide about fetch_types. It is false on the half where one round trip matters most and default on the half that connects once and stays, which is backwards from how I would guess, but only one of those two choices was actually made on purpose.

If you want to see the half of the product that does reach a connection, the free tier needs no card, and the first thing you do after signing up, creating a venue, is three writes through the client with a pool size of one.

Top comments (0)