How to Implement Database Connection Pooling in Node.js with PostgreSQL and PgBouncer

How to Implement Database Connection Pooling in Node.js with PostgreSQL and PgBouncer

by | Oct 14, 2026 | Uncategorized | 0 comments

Your Node.js API works perfectly in development. You deploy it to Kubernetes with six replicas, or push it to a serverless platform, and within minutes PostgreSQL starts screaming:

error: sorry, too many clients already
    at Parser.parseErrorMessage (/app/node_modules/pg-protocol/dist/parser.js:287:98)
  severity: 'FATAL',
  code: '53300'

This is the single most common production failure in Node.js and PostgreSQL stacks, and it has almost nothing to do with your code quality. It is an architecture problem: Node.js scales horizontally, PostgreSQL connections do not.

This guide walks through the full fix, layer by layer: correct pg Pool configuration, a real pool sizing formula, PgBouncer in transaction mode, and the Prisma connection settings that everyone gets wrong. Everything below is what we actually deploy for clients at Box Software.

Why Node.js Apps Exhaust PostgreSQL Connections

PostgreSQL uses a process-per-connection model. Every single connection forks a backend process on the database server that consumes memory (roughly 5 to 15 MB depending on workload) and CPU for context switching. That is why the default max_connections is only 100, and why managed providers cap it aggressively:

Environment Typical max_connections Notes
Local Docker Postgres 100 Default, you never hit it in dev
AWS RDS db.t3.micro ~81 Calculated from instance memory
Small managed plans (Supabase, Neon, Render) 15 to 60 direct Pooled endpoint offered separately
Large self-hosted instance 200 to 500 Beyond this, throughput drops

Now count what your Node.js deployment actually opens.

The multiplication problem in containers

The pg module ships with built-in pooling through pg-pool, and its default max is 10 clients per pool. That default is per process, not per application.

  • 1 pod, 1 Node process, default pool = 10 connections
  • 6 pods behind a load balancer = 60 connections
  • 6 pods running Node in cluster mode with 4 workers = 240 connections
  • Plus a cron worker, a queue consumer, a migration job and your own psql session

You have blown past max_connections while your CPU sits at 8 percent. Autoscaling makes it worse: the moment traffic spikes, HPA adds pods, each new pod grabs another 10 connections, and the database rejects everyone including the healthy pods.

The serverless problem

Serverless functions (Lambda, Vercel, Cloud Run scaling to many instances) are worse because concurrency equals instances. Each cold start creates a new execution context, each context creates a new pool, and the database has no idea those connections will be idle 99 percent of the time. Related reading: Postgres Node.js Connection Pool.

Classic mistakes we see in audits:

  1. Creating new Pool() inside the handler function instead of at module scope, so every invocation opens fresh connections.
  2. Using new Client() per request and forgetting client.end() on the error path.
  3. Leaving the default pool size of 10 in a function that can only process one request at a time.
  4. Assuming the platform “handles it”, when in reality nothing sits between the function and PostgreSQL.
database server connections

Real Symptoms and What They Mean

Symptom Root cause
FATAL: sorry, too many clients already (SQLSTATE 53300) Total connections across all processes exceeded max_connections
remaining connection slots are reserved for non-replication superuser connections You reached max_connections minus superuser_reserved_connections
Error: timeout exceeded when trying to connect pg pool is saturated, requests queue past connectionTimeoutMillis
Prisma P2024: Timed out fetching a new connection from the connection pool Prisma pool too small, or slow queries holding connections
Latency climbs but CPU on the DB is low Requests waiting in the pool queue, not in the database
Connections keep growing after deploys and never drop Leaked clients, missing release(), or no graceful shutdown

Diagnose in 30 seconds

Run this against your database before changing anything:

-- Who is connected and what are they doing?
SELECT application_name, state, count(*)
FROM pg_stat_activity
GROUP BY 1, 2
ORDER BY 3 DESC;

-- How close are we to the limit?
SELECT count(*) AS used,
       current_setting('max_connections')::int AS max_allowed
FROM pg_stat_activity;

-- Idle connections older than 5 minutes are pure waste
SELECT pid, application_name, state, now() - state_change AS idle_for
FROM pg_stat_activity
WHERE state = 'idle'
ORDER BY idle_for DESC;

If you see dozens of rows in state idle, your pools are hoarding connections. If you see many in idle in transaction, you have a transaction leak, which is a bug you must fix before any pooler will save you.

database server connections

Layer 1: Configure the pg Pool Correctly

node-postgres ships with connection pooling built in. You should never use new Client() per HTTP request in a server. Create exactly one pool per process, export it, and reuse it. Much the same conclusion turns up on oneuptime.com.

A production-ready pool module

// db.js
import pg from 'pg';

const { Pool } = pg;

export const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
  max: Number(process.env.PG_POOL_MAX ?? 10),
  min: 0,
  idleTimeoutMillis: 30_000,
  connectionTimeoutMillis: 5_000,
  maxLifetimeSeconds: 1800,
  allowExitOnIdle: false,
  application_name: process.env.SERVICE_NAME ?? 'api',
  statement_timeout: 15_000,
  query_timeout: 15_000,
  keepAlive: true
});

pool.on('error', (err) => {
  console.error('Unexpected idle client error', err);
});

// Always prefer pool.query() for single statements:
// it checks out, runs, and releases automatically.
export const query = (text, params) => pool.query(text, params);

// Use this helper for transactions so you never leak a client.
export async function withTransaction(fn) {
  const client = await pool.connect();
  try {
    await client.query('BEGIN');
    const result = await fn(client);
    await client.query('COMMIT');
    return result;
  } catch (err) {
    await client.query('ROLLBACK');
    throw err;
  } finally {
    client.release();
  }
}

What each option actually does

Option Default Recommendation
max 10 Set explicitly from an env var. See the sizing formula below.
idleTimeoutMillis 10000 10s to 30s. Returns unused connections to the database.
connectionTimeoutMillis 0 (wait forever) Never leave at 0. 3s to 5s so requests fail fast instead of piling up.
maxLifetimeSeconds 0 (unlimited) 1800. Recycles connections, helps with failovers and DNS changes.
statement_timeout none Server-side guard so one runaway query cannot hold a slot forever.
allowExitOnIdle false true for scripts and one-shot jobs, false for long-running servers.
keepAlive false true when a NAT gateway or load balancer sits in the path.

Shut down gracefully

Kubernetes sends SIGTERM before killing the pod. If you ignore it, connections linger until the server times them out, which stacks up during rolling deploys.

const shutdown = async (signal) => {
  console.log(`${signal} received, draining pool`);
  server.close(async () => {
    await pool.end();
    process.exit(0);
  });
  setTimeout(() => process.exit(1), 10_000).unref();
};

['SIGTERM', 'SIGINT'].forEach((s) => process.on(s, () => shutdown(s)));

How to Size Your Node.js PostgreSQL Connection Pool

The instinct is to raise max when things get slow. That is usually the wrong move. PostgreSQL throughput peaks at a fairly low number of active connections, then degrades because of lock contention and context switching.

The budget formula

pool.max = (max_connections - reserved) / total_node_processes

where:
  reserved = superuser slots + migrations + admin tools + replication
  total_node_processes = replicas x cluster_workers, at MAX autoscale

Worked example

  • max_connections = 100
  • Reserved for psql, migrations, monitoring and backups = 15
  • API autoscales up to 8 pods, single Node process each
  • Background worker: 2 pods

Budget = 85 connections. API gets 64, workers get 16, leaving headroom. So API max = 8 and worker max = 8. Note that this is smaller than the default of 10, not bigger.

Starting values by architecture

Architecture pg pool max per process Needs PgBouncer?
Single VM, one Node process 10 to 20 No
Containers, 2 to 10 replicas 5 to 10 Recommended
Containers, 10+ replicas or aggressive HPA 3 to 5 Yes
Serverless functions 1 (sometimes 2) Mandatory
Batch or ETL worker Match your concurrency, cap at 10 Optional

Rule of thumb: if your average query takes 5 ms, a single connection can serve roughly 200 queries per second. Most APIs need far fewer connections than developers assume. If the pool is saturated, fix the slow queries and missing indexes first.

database server connections

Layer 2: PgBouncer in Transaction Mode

Application-side pools cannot coordinate with each other. PgBouncer solves that by sitting in front of PostgreSQL as a single, lightweight multiplexer: thousands of client connections in, a few dozen server connections out.

Choosing a pool mode

Mode Server connection released when Multiplexing Use for
session Client disconnects Almost none Legacy apps, session features, migrations
transaction Transaction commits or rolls back Excellent Node.js APIs, serverless, containers
statement Each statement ends Maximum Read-only analytics, breaks multi-statement transactions

A working pgbouncer.ini

[databases]
app = host=10.0.1.20 port=5432 dbname=app

[pgbouncer]
listen_addr = 0.0.0.0
listen_port = 6432
auth_type = scram-sha-256
auth_file = /etc/pgbouncer/userlist.txt

pool_mode = transaction
max_client_conn = 2000
default_pool_size = 25
min_pool_size = 5
reserve_pool_size = 5
reserve_pool_timeout = 3
max_db_connections = 80

server_idle_timeout = 240
server_lifetime = 3600
query_wait_timeout = 10

# Required for named prepared statements in transaction mode
max_prepared_statements = 200

ignore_startup_parameters = extra_float_digits,search_path

The two numbers that matter

  • max_client_conn: how many Node.js connections PgBouncer will accept. Set it generously (1000 to 5000), because these are cheap.
  • default_pool_size: how many real PostgreSQL connections are used per database and user pair. This is the number that must respect max_connections. Start with 2 to 4 times your database CPU core count.

Also make sure max_db_connections plus your reserved admin slots stays under max_connections. If you run several PgBouncer instances, divide the pool size by the number of instances.

What breaks in transaction mode

Transaction pooling means any session state is unreliable because consecutive queries may land on different backends. Avoid or handle:

  • SET / SET LOCAL outside a transaction, including search_path tricks for multi-tenancy
  • LISTEN / NOTIFY (use a dedicated direct connection for these)
  • Session-level advisory locks (use transaction-level: pg_advisory_xact_lock)
  • WITH HOLD cursors and temporary tables that must survive a transaction
  • Schema migrations and CREATE INDEX CONCURRENTLY: always run these against a direct connection on port 5432

Pointing node-postgres at PgBouncer

Change the port and shrink the application pool. PgBouncer is now your real pool, so pg only needs enough sockets to keep the event loop busy:

DATABASE_URL=postgres://app_user:[email protected]:6432/app
PG_POOL_MAX=5
# Keep a separate direct URL for migrations
DIRECT_DATABASE_URL=postgres://app_user:[email protected]:5432/app

Modern PgBouncer (1.21 and later) supports protocol-level prepared statements in transaction mode, which is why max_prepared_statements is set above. If you are stuck on an older build, disable named prepared statements in your client instead.

Layer 3: Prisma and Other ORMs

Prisma maintains its own Rust-side connection pool, and its default is surprisingly aggressive: num_physical_cpus * 2 + 1. On a 4 vCPU node that is 9 connections per instance, per process.

Connection string parameters

# Through PgBouncer, transaction mode
DATABASE_URL="postgresql://user:pass@pgbouncer:6432/app?pgbouncer=true&connection_limit=5&pool_timeout=10&connect_timeout=5"

# Direct connection, used only by migrate and introspection
DIRECT_URL="postgresql://user:pass@db:5432/app"
// schema.prisma
datasource db {
  provider  = "postgresql"
  url       = env("DATABASE_URL")
  directUrl = env("DIRECT_URL")
}
Parameter Purpose Suggested value
connection_limit Max connections per Prisma Client instance 1 serverless, 3 to 5 behind PgBouncer
pool_timeout Seconds to wait for a free connection before P2024 10 (0 disables the timeout, use with care)
connect_timeout Seconds to establish a new connection 5
pgbouncer=true Disables client-side prepared statement caching Required in transaction mode

The Prisma singleton (dev hot reload killer)

In development, every hot reload creates a new PrismaClient and a new pool. Two minutes of editing and your database is full.

// prisma.js
import { PrismaClient } from '@prisma/client';

const globalForPrisma = globalThis;

export const prisma =
  globalForPrisma.prisma ?? new PrismaClient({ log: ['warn', 'error'] });

if (process.env.NODE_ENV !== 'production') {
  globalForPrisma.prisma = prisma;
}

The same principle applies to Sequelize (pool.max, pool.min, pool.idle), TypeORM (extra.max), Knex (pool: { min, max }) and Drizzle, which simply wraps a pg Pool you control yourself.

database server connections

Serverless: The Special Case

In a serverless runtime, each concurrent invocation is isolated. Pooling inside the function has limited value, so the strategy changes:

  1. Declare the pool or Prisma client at module scope, outside the handler, so warm invocations reuse it.
  2. Set max or connection_limit to 1. One invocation handles one request, so more is waste.
  3. Set idleTimeoutMillis low so frozen instances release connections quickly.
  4. Always connect through an external pooler: PgBouncer, RDS Proxy, or your provider’s pooled endpoint (Supabase pooler on port 6543, Neon pooler endpoint).
  5. For edge runtimes where TCP is unavailable, use an HTTP driver such as Neon’s serverless driver or Prisma Accelerate.
  6. Never leave connectionTimeoutMillis at 0, or a saturated pooler will keep your functions alive until they time out and cost you money.
// handler.js (Lambda / Vercel style)
import pg from 'pg';

const pool = new pg.Pool({
  connectionString: process.env.DATABASE_URL, // pooler endpoint
  max: 1,
  idleTimeoutMillis: 10_000,
  connectionTimeoutMillis: 3_000,
  allowExitOnIdle: true
});

export const handler = async (event) => {
  const { rows } = await pool.query('SELECT id, email FROM users WHERE id = $1', [event.userId]);
  return { statusCode: 200, body: JSON.stringify(rows[0] ?? null) };
};

Monitor the Pool, Not Just the Database

node-postgres exposes three counters that tell you everything. Export them to Prometheus or your APM:

setInterval(() => {
  const { totalCount, idleCount, waitingCount } = pool;
  metrics.gauge('pg.pool.total', totalCount);
  metrics.gauge('pg.pool.idle', idleCount);
  metrics.gauge('pg.pool.waiting', waitingCount);
}, 10_000);
Signal Interpretation Action
waitingCount > 0 regularly Pool saturated Optimise slow queries first, then raise max if budget allows
idleCount close to totalCount Pool oversized Lower max, give the budget to other services
totalCount never decreases Leaked clients Audit every pool.connect() for a matching release()

On the PgBouncer side, connect to the admin console and watch SHOW POOLS;. A growing cl_waiting column means default_pool_size is too small or queries are too slow. SHOW STATS; gives you average query and wait times per database.

database server connections

Implementation Checklist

  1. Measure current usage with pg_stat_activity and note your real max_connections.
  2. Create one pool per process in a shared module, never inside a request handler.
  3. Set max explicitly from an environment variable and compute it with the budget formula.
  4. Set connectionTimeoutMillis, idleTimeoutMillis and a server-side statement_timeout.
  5. Wrap transactions in a helper with try/finally and client.release().
  6. Handle SIGTERM and call pool.end().
  7. Deploy PgBouncer in transaction mode as soon as you have more than a couple of replicas.
  8. Keep a separate direct URL for migrations, LISTEN/NOTIFY and maintenance.
  9. Set connection_limit and pgbouncer=true for Prisma.
  10. Export pool metrics and alert on waitingCount.
  11. Load test at your maximum replica count, not your minimum.

FAQ

Does PostgreSQL support connection pooling natively?

Not in the way applications need. PostgreSQL has no built-in pooler process, so every client connection maps to a dedicated backend process. Pooling has to happen either in your application (the pg module’s Pool) or in a middleware such as PgBouncer, pgcat or a managed proxy like RDS Proxy.

Do I still need pg Pool if I use PgBouncer?

Yes, and you should keep it small. The pg pool avoids the TCP and TLS handshake cost on every query, while PgBouncer prevents the database from being overwhelmed globally. A small application pool (3 to 5) pointing at PgBouncer is the standard combination.

What pool size should I use in production?

Start with (max_connections - reserved) / number_of_processes at maximum scale. For most Node.js APIs behind PgBouncer that lands between 3 and 10 per process. Bigger pools rarely improve throughput because PostgreSQL performance flattens once active connections exceed a small multiple of the CPU core count. The point is broken down further in this analysis.

Why do I get “too many clients already” even with a pool configured?

Usually because the pool is multiplied by processes you forgot to count: extra replicas, Node cluster workers, cron jobs, queue consumers, CI migration jobs, preview environments sharing the same database, and hot reload in development. Add them all up against max_connections.

What is the difference between session mode and transaction mode in PgBouncer?

Session mode assigns a real PostgreSQL connection to a client for its entire session, giving almost no multiplexing. Transaction mode returns the server connection to the pool after each transaction, letting hundreds of Node.js clients share a handful of backends. Transaction mode is the right choice for stateless HTTP APIs, provided you avoid session-level features.

Is node-postgres or postgres.js better for pooling?

Both are solid. pg (node-postgres) is the most widely deployed and integrates with nearly every ORM. postgres.js is faster in benchmarks, creates connections lazily and has a cleaner tagged-template API, but its prepared statement behaviour needs the same PgBouncer care. The pooling principles and sizing maths in this article apply to both.

Can I just increase max_connections instead?

Raising max_connections to 1000 will make the error disappear and your performance collapse. Each backend consumes memory and scheduler time, and contention on shared buffers and locks grows quickly. Increase it modestly if you have the RAM, but fix pooling first.

Need Help Sizing Your Stack?

Connection exhaustion is usually a symptom of a wider architecture issue: slow queries holding connections, missing indexes, no graceful shutdown, or a scaling policy nobody budgeted for. Our team at Box Software audits and tunes Node.js and PostgreSQL deployments, from pool configuration to PgBouncer rollout and query optimisation. Get in touch if you want a second pair of eyes on your production database.