Serverless Postgres Connection Pooling: Pick One

Serverless Postgres connection pooling needs two pools, not one. Put a transaction-mode pooler (PgBouncer, Supavisor, RDS Proxy or Hyperdrive) between your functions and the database, then shrink the client pool inside each function to one or two connections. The pooler absorbs the concurrency. The client pool only exists to avoid repeated TCP and TLS setup.

Why serverless functions run Postgres out of connections

Postgres forks a backend process per connection, so max_connections is a hard ceiling, and a small managed instance often sits at 100 or lower. A serverless platform gives every concurrent invocation its own isolated copy of your module, and each of those brings its own client pool.

node-postgres defaults max to 10. Two hundred concurrent invocations therefore reach for up to two thousand backends against a ceiling of a hundred, and the database says so:

FATAL:  sorry, too many clients already
FATAL:  remaining connection slots are reserved for non-replication superuser connections

The second failure mode is quieter. Every fresh connection pays a TLS handshake plus the Postgres startup exchange before a single row moves. Cloudflare’s Hyperdrive documentation puts the per-query cost at “20-30ms from a distant region, or 1-3ms when placed nearby”. Pay that on every cold invocation and your p95 is connection setup rather than query time.

What transaction-mode pooling breaks

A pooler in transaction mode hands the server connection back to the pool at COMMIT, so the next statement from your function may land on a completely different backend. That is what makes the ratio work, and it is also what breaks things.

PgBouncer lists the casualties explicitly: SET and RESET, LISTEN, WITH HOLD cursors, PREPARE and DEALLOCATE, temporary tables meant to outlive the transaction, LOAD, and session-level advisory locks. Swap pg_advisory_lock for pg_advisory_xact_lock, which releases at commit and is safe here. Anything that sets a session variable belongs in the connection string or in the pooler’s own configuration instead.

Protocol-level prepared statements are fine. PgBouncer supports them in transaction mode once max_prepared_statements is non-zero, and Neon notes this works from PgBouncer 1.22.0. SQL-level PREPARE still does not.

If your ORM emits named prepared statements you will meet this error under load, once two clients share a backend:

prepared statement "s0" already exists

Prisma’s fix is version-dependent. Below PgBouncer 1.21.0 you add ?pgbouncer=true to the URL, which disables named prepared statements; from 1.21.0 that flag is no longer recommended. Either way the schema engine cannot run migrations through a pooler, so you keep a second DIRECT_URL and point the CLI at it. Drizzle sidesteps most of this by not using named prepared statements by default, one of the practical differences behind our Drizzle and Prisma comparison.

Which connection pooling setup fits a serverless Postgres app

The answer is mostly decided by where the database already lives.

Neon ships PgBouncer in front of every project. Add -pooler to the endpoint ID in the hostname, so ep-cool-darkness-123456.us-east-2.aws.neon.tech becomes ep-cool-darkness-123456-pooler.us-east-2.aws.neon.tech. That endpoint runs pool_mode=transaction, accepts up to 10,000 client connections, and sizes the per-user, per-database server pool at 0.9 times max_connections.

Supabase uses Supavisor. Transaction mode is aws-[region].pooler.supabase.com:6543 and is the one the connection docs recommend for serverless and edge functions. Session mode is the same host on 5432, for long-lived processes. The direct connection at db.[project-id].supabase.co:5432 is IPv6 only unless you have the IPv4 add-on, which catches people out on CI runners more often than in production.

RDS and Aurora get RDS Proxy, which listens on 5432 for Postgres and must sit in the same VPC as the database. The thing to watch is pinning. When RDS Proxy sees session state it cannot share, it ties your client to one backend until the connection drops, and multiplexing stops paying for itself. For Postgres, the pinning list includes SET, PREPARE/DISCARD/DEALLOCATE/EXECUTE, temporary tables, views and sequences, declared cursors, LISTEN, loading a module such as auto_explain, nextval and setval, and pg_advisory_lock. Transaction-scoped advisory locks are exempt. Any statement over 16 KB of text pins the session on every engine, which is worth knowing if you generate large INSERT batches. Watch the DatabaseConnectionsCurrentlySessionPinned CloudWatch metric and move any per-connection SET into the proxy’s initialization query.

Two more RDS Proxy details bite in practice: it does not support CancelRequest, so cancelling a slow query from the client does nothing, and a pool library configured to issue DISCARD ALL as its reset query pins the connection on release.

Cloudflare Workers get Hyperdrive, which is a different shape. The pool lives in regions near your origin database while the connection setup happens between the Worker and Hyperdrive at the edge, so the expensive handshakes stop crossing an ocean. Query caching is on by default at max_age 60 seconds with stale_while_revalidate 15 seconds, the same semantics covered in our CDN cache-control write-up. Writes are never cached, and neither are reads containing NOW(), CURRENT_TIMESTAMP, CURRENT_DATE, RANDOM() or LASTVAL(). Turn it off per config with --caching-disabled if 60 seconds of staleness is not acceptable:

npx wrangler hyperdrive create app-db-fresh \
  --connection-string="postgres://user:[email protected]:5432/app" \
  --caching-disabled

The Worker then talks to a local binding rather than a remote host:

import postgres from 'postgres';

export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    const sql = postgres(env.HYPERDRIVE.connectionString, { max: 5 });

    try {
      const rows = await sql`SELECT id, title FROM posts ORDER BY id DESC LIMIT 20`;
      return Response.json(rows);
    } finally {
      ctx.waitUntil(sql.end());
    }
  },
} satisfies ExportedHandler<Env>;

If you are already weighing Workers-native storage instead, KV, D1 and Durable Objects solve a different problem and are worth ruling in or out before you add a pooler.

How to size the client pool inside the function

Once a pooler is in front, the pool inside your function should be small and long-lived. Create it at module scope so warm invocations reuse it, and cap it at the number of queries one invocation runs in parallel, which is usually one.

import { Pool } from 'pg';

// Module scope: survives warm invocations, one pool per instance.
const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
  max: 1,
  idleTimeoutMillis: 30_000,
  connectionTimeoutMillis: 5_000,
  maxLifetimeSeconds: 600,
  allowExitOnIdle: true,
});

export async function handler(event: { id: string }) {
  const { rows } = await pool.query(
    'SELECT id, title FROM posts WHERE id = $1',
    [event.id],
  );
  return rows[0] ?? null;
}

Three of those settings matter more than the rest. connectionTimeoutMillis defaults to 0, meaning no timeout at all, so a saturated pooler leaves your function hanging until the platform kills it; give it a real number below your function timeout. maxLifetimeSeconds defaults to disabled and forces rotation, which matters when the pooler behind you moves. allowExitOnIdle lets the event loop drain so a Lambda can freeze cleanly instead of being suspended mid-socket.

When to skip the pooler entirely

If a request runs one query, or a handful that do not need to share a transaction, HTTP removes the problem rather than managing it. Neon’s neon() function from @neondatabase/serverless sends a query as a single fetch and holds no connection state:

import { neon } from '@neondatabase/serverless';

const sql = neon(process.env.DATABASE_URL!);
const posts = await sql`SELECT id, title FROM posts WHERE author_id = ${authorId}`;

The cost is that there is no session and no interactive transaction, and the request and response cap out at 64 MB. Where you need BEGIN and a decision in the middle, the Pool and Client exports from the same package speak the wire protocol over WebSockets and behave like node-postgres.

Default to the pooler your provider already runs, in transaction mode, with max: 1 inside the function. Reach for HTTP when the workload is genuinely one query per request and you would rather delete the pool than tune it. Keep a session-mode or direct URL alongside it for migrations, LISTEN/NOTIFY workers and anything that needs SET to stick. The one setup worth avoiding is a large client pool pointed straight at Postgres, because it looks fine until traffic arrives.

Need this built properly?

Whoooop Ltd has spent 15+ years building and maintaining web applications in TypeScript, React, Node.js and serverless — the same ground this post covers.

Get in touch