DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Our production Postgres pool is three connections and our dev pool is five, and prepared statements are off

The whole database client for Munchable's web app is twenty nine lines. It is the file I would show someone first if they asked what is different about running Postgres behind a serverless app, because almost every line in it is a concession to that one fact.

const createClient = () =>
  postgres(process.env.DATABASE_URL!, {
    // Supabase Transaction Pooler (PgBouncer, port 6543) does not support
    // prepared statements in transaction mode.
    prepare: false,
    max: process.env.NODE_ENV === 'production' ? 3 : 5,
    idle_timeout: 10,
    max_lifetime: process.env.NODE_ENV === 'production' ? 60 * 5 : 60 * 30,
    connect_timeout: 10,
  });

const client = global.__db_client ?? createClient();
if (process.env.NODE_ENV !== 'production') global.__db_client = client;

export const db = drizzle(client, { schema });
Enter fullscreen mode Exit fullscreen mode

prepare: false is not a performance choice

postgres.js prepares statements by default, which is the right default: you pay the parse and plan once and reuse it. It is also the one thing transaction-mode pooling cannot give you.

A transaction pooler hands you a real backend connection for the duration of a transaction and then gives that connection to somebody else. A protocol-level prepared statement lives on the backend connection, so the name you prepared on one transaction is not there on the next, or worse, is there and belongs to a different statement. The failure is not subtle, but it is intermittent in the way that depends on how busy the pooler is, which is the worst shape of bug to meet in production.

So prepare: false goes in, and the cost is real: every query is parsed and planned again. For this app that cost is near zero, because the queries the request path actually runs are single-row index hits, and the expensive reads sit behind a Redis cache rather than behind a plan cache.

Why production gets fewer connections than my laptop

This is the line that looks backwards until you count instances.

max: process.env.NODE_ENV === 'production' ? 3 : 5,
Enter fullscreen mode Exit fullscreen mode

Locally there is one Node process. Its pool is the whole truth: five connections, and five is what the database sees.

In production there is no single process. Each serverless instance evaluates this module itself and gets its own pool, so the number the pooler sees is max times however many instances are warm. Three is not a statement about how much concurrency the app needs; it is a statement about what one instance is allowed to hold while twenty of them exist. Pick a comfortable looking 10 here and a traffic spike turns into two hundred client connections arriving at a pooler whose job is to multiplex a much smaller number of real ones.

The instinct from a long-running server is "make the pool big enough for peak concurrency". The serverless version is "make the pool small enough that peak instance count does not become peak connection count", and the pooler absorbs the difference.

The singleton exists for the dev server, not for production

const client = global.__db_client ?? createClient();
if (process.env.NODE_ENV !== 'production') global.__db_client = client;
Enter fullscreen mode Exit fullscreen mode

Stashing a client on globalThis is a well known Next.js pattern, and it is a development fix rather than a production one. The dev server re-evaluates modules as you edit; without the cache you get a fresh pool per reload, each one holding connections, until the database starts refusing new ones and you blame your query.

Writing to the global in production would be harmless but pointless, since the module is evaluated once per instance anyway, and a global write in a hot module is the sort of thing that outlives its reason. Keeping the branch makes it obvious which problem the line is for.

The two lines with no comment on them

idle_timeout: 10 and max_lifetime are the only settings in the file that nobody wrote a reason for, so in the interest of not pretending the code documents itself: they are a judgement call.

Short idle timeout, because an idle connection held by an instance that will not get another request is a connection taken away from an instance that would. Five minutes of maximum lifetime in production, because connections through a pooler are cheap to replace and a long-lived one survives across deploys and pooler restarts in ways that are hard to reason about. Thirty minutes locally, because the only thing a short lifetime buys on a laptop is reconnection noise.

If someone shows me evidence that either number is wrong, there is nothing in the design resting on them. That is worth saying out loud about config: some of it is load-bearing, like prepare: false, and some of it is a starting point that has never been measured.

What never touches this pool at all

The pages you can read on the public site are not clients of any of this. The condition pages, the generated question pages and the recipe pages are prerendered at build time, with the route segments marked so a build fails rather than silently going dynamic. You can see the whole set: munchable.app/conditions is the seven condition guides, munchable.app/answers indexes the generated question pages, and munchable.app/recipes is the recipe library. Not one of those requests opens a connection.

What the pool serves is the small set of routes that genuinely need the database: a barcode lookup on a cache miss, a contribution write, the billing and account routes, and the admin panel. Reads dominate, they are index hits, and the hold time per connection is measured in milliseconds, which is the condition under which a three connection pool and a transaction pooler are a sensible pair.

The general shape of it: in a serverless app the pool size is not about your traffic, the prepared statement setting is not about speed, and the one pattern everybody copies from the Next.js docs is there for the dev server.

Top comments (0)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.