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:
- Creating
new Pool()inside the handler function instead of at module scope, so every invocation opens fresh connections. - Using
new Client()per request and forgettingclient.end()on the error path. - Leaving the default pool size of 10 in a function that can only process one request at a time.
- Assuming the platform “handles it”, when in reality nothing sits between the function and PostgreSQL.

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.

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.

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 respectmax_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 LOCALoutside a transaction, includingsearch_pathtricks for multi-tenancyLISTEN/NOTIFY(use a dedicated direct connection for these)- Session-level advisory locks (use transaction-level:
pg_advisory_xact_lock) WITH HOLDcursors 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.

Serverless: The Special Case
In a serverless runtime, each concurrent invocation is isolated. Pooling inside the function has limited value, so the strategy changes:
- Declare the pool or Prisma client at module scope, outside the handler, so warm invocations reuse it.
- Set
maxorconnection_limitto 1. One invocation handles one request, so more is waste. - Set
idleTimeoutMillislow so frozen instances release connections quickly. - Always connect through an external pooler: PgBouncer, RDS Proxy, or your provider’s pooled endpoint (Supabase pooler on port 6543, Neon pooler endpoint).
- For edge runtimes where TCP is unavailable, use an HTTP driver such as Neon’s serverless driver or Prisma Accelerate.
- Never leave
connectionTimeoutMillisat 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.

Implementation Checklist
- Measure current usage with
pg_stat_activityand note your realmax_connections. - Create one pool per process in a shared module, never inside a request handler.
- Set
maxexplicitly from an environment variable and compute it with the budget formula. - Set
connectionTimeoutMillis,idleTimeoutMillisand a server-sidestatement_timeout. - Wrap transactions in a helper with
try/finallyandclient.release(). - Handle SIGTERM and call
pool.end(). - Deploy PgBouncer in
transactionmode as soon as you have more than a couple of replicas. - Keep a separate direct URL for migrations,
LISTEN/NOTIFYand maintenance. - Set
connection_limitandpgbouncer=truefor Prisma. - Export pool metrics and alert on
waitingCount. - 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.
