Fixing "too many connections" in PostgreSQL: connection pooling for serverless and Node.js
"FATAL: sorry, too many clients already" means your app opened more connections than PostgreSQL allows. Learn the max_connections math, how to size a pg pool, and when to use PgBouncer or a managed pooler for serverless.
Muhammad Faizan
Founder & Lead Engineer
Under load, or right after you move to serverless, requests start failing with FATAL: sorry, too many clients already, or with remaining connection slots are reserved for superuser roles. PostgreSQL refuses new connections because its max_connections limit is reached. The database itself is often far from busy; it is simply out of connection slots.
The fix is rarely to raise the limit. It is to control how many connections your application opens and to put a pooler in front of the database when the number of clients is unpredictable.
Why it happens
Every PostgreSQL connection is a dedicated server process. The default max_connections is 100, and a few of those are reserved (superuser_reserved_connections defaults to 3, and managed providers often reserve more for their own tooling). Meanwhile, every application process keeps its own pool. The arithmetic that matters is:
(number of app processes) × (pool size per process) + migrations, workers, cron jobs and admin sessions ≤ max_connections − reserved slots
With node-postgres, new Pool() defaults to max: 10. Ten pods with four Node.js workers each is already 400 potential connections. On serverless platforms the number of processes is not even fixed: each concurrent invocation can get its own instance, and each instance its own pool. Other common causes:
- Creating a new
Poolor ORM client per request instead of once per process. - Next.js hot reload in development creating a new client on every file change.
- Connections left idle in transaction by code paths that never commit or roll back, or that never call
client.release(). - Long-running queries or slow downstream calls holding a connection while they wait.
Serverless makes this worse in a specific way. A function instance that has finished handling requests is frozen, not destroyed, and the TCP connections in its pool stay open on the database side until the platform recycles the instance or Postgres times them out. A burst of traffic can leave dozens of frozen instances each holding connections that nobody is using.
Step 1: See who is holding connections
Before changing anything, look at pg_stat_activity. It shows every connection, which application and user opened it, and what it is doing. Lots of idle connections point to oversized pools; idle in transaction points to a code bug.
-- Connections grouped by app, user and state
SELECT application_name, usename, state, count(*)
FROM pg_stat_activity
WHERE backend_type = 'client backend'
GROUP BY 1, 2, 3
ORDER BY count(*) DESC;
-- Sessions stuck inside a transaction
SELECT pid, application_name, now() - xact_start AS open_for, left(query, 80) AS query
FROM pg_stat_activity
WHERE state = 'idle in transaction'
ORDER BY open_for DESC;
SHOW max_connections;Set application_name in each service's connection string so this view tells you which deployment is responsible. As a safety net, idle_in_transaction_session_timeout makes Postgres terminate sessions that sit in an open transaction too long.
Step 2: Size the pool in long-running Node.js apps
Create exactly one pool per process, at module scope, and size it from the budget above rather than leaving the default. More connections do not mean more throughput: Postgres does its best work with active connections in the region of a small multiple of its CPU cores. If requests wait for a connection, that is usually a sign of slow queries, not a small pool.
import { Pool, type PoolClient } from "pg";
// Budget: max_connections 100, minus ~10 reserved/admin,
// shared by 6 API processes and 2 workers => ~10 each at most.
const globalForDb = globalThis as unknown as { pool?: Pool };
export const pool =
globalForDb.pool ??
new Pool({
connectionString: process.env.DATABASE_URL,
application_name: "orders-api",
max: Number(process.env.DB_POOL_MAX ?? 8),
idleTimeoutMillis: 30_000, // close idle clients
connectionTimeoutMillis: 5_000, // fail fast instead of queueing forever
});
// Reuse across hot reloads in development
if (process.env.NODE_ENV !== "production") globalForDb.pool = pool;
export async function withTransaction<T>(fn: (client: PoolClient) => Promise<T>) {
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(); // always return the connection
}
}Step 3: Put a pooler in front of Postgres for serverless
When the number of application instances is elastic, per-process pools cannot be budgeted reliably. The answer is a connection pooler between the app and the database: thousands of client connections are multiplexed onto a small, fixed number of real Postgres connections. PgBouncer is the standard choice; managed platforms such as Neon, Supabase and AWS RDS Proxy provide equivalents.
Use transaction mode for serverless and web workloads. A server connection is assigned to a client only for the duration of a transaction, then returned, so idle function instances hold no database connections. The trade-off is that session state does not persist between transactions: avoid SET without LOCAL, session-level advisory locks, LISTEN/NOTIFY and temporary tables across transactions. Run migrations and anything that needs session state over a direct, unpooled connection.
[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 ; client connections PgBouncer accepts
default_pool_size = 20 ; real Postgres connections per db/user pair
reserve_pool_size = 5
max_prepared_statements = 200 ; protocol-level prepared statements (1.21+)
server_idle_timeout = 60In each serverless function, keep the pool tiny (max: 1 to 3) and point it at the pooler. The pooler does the real pooling; the in-process pool only avoids reconnecting on every invocation within a warm instance.
Pair the pooler with server-side timeouts. A statement_timeout on the application role stops a runaway query from pinning a pooled connection, and idle_in_transaction_session_timeout cleans up sessions whose client crashed mid-transaction. Without them, a handful of stuck clients can exhaust even a well-sized pool, and the error moves from Postgres to the pooler's own wait queue.
Prisma and other ORMs
ORMs maintain their own pools, so the same arithmetic applies. With Prisma's built-in query engine, the pool size is set with the connection_limit parameter on the connection URL, and pgbouncer=true adjusts its behaviour for transaction-mode poolers. With a driver adapter such as @prisma/adapter-pg, you configure the pg pool directly, as above. Prisma Accelerate, the successor to the Data Proxy, is a hosted pooler aimed at serverless and edge runtimes. Whatever the tool, instantiate the client once per process and keep the globalThis trick for development.
Prevention tips
- Write down the connection budget and set every pool size explicitly from it, including workers and cron jobs.
- Alert when connections exceed around 80% of
max_connections. - Always release clients in a
finallyblock and set a statement timeout so slow queries cannot hold connections forever. - Use a pooler from day one if you deploy to serverless or autoscale aggressively.
- Load test the scale-out path, not just a single instance, so the connection count is part of the test.
Key takeaways
- "Too many clients" is a connection-count problem, not usually a database capacity problem.
- Total connections equal processes times pool size plus everything else; budget it explicitly.
- Create one pool per process, size it deliberately, and always release clients.
- For serverless and elastic workloads, put PgBouncer or a managed pooler in transaction mode in front of Postgres.
If your Postgres setup is buckling as traffic grows, our backend team can audit pooling, queries and indexing. Tell us about your stack.
Frequently asked questions
Should I just increase max_connections in PostgreSQL?
Only modestly. Each connection is a separate backend process with its own memory, and throughput usually drops once active connections far exceed CPU cores. A pooler in front of a moderate max_connections is the better fix.
Why do serverless functions exhaust Postgres connections so quickly?
Each concurrent function instance runs its own copy of your code, and so its own pool. A traffic spike creates many instances at once, each opening connections, and idle instances can keep theirs open until they are recycled.
Does PgBouncer transaction mode break prepared statements?
It used to. PgBouncer 1.21 and later can track protocol-level prepared statements when max_prepared_statements is set. Session-level features such as SET, advisory locks held across transactions and LISTEN still do not work in transaction mode.
Need help with backend & apis?
Secure, scalable backends and APIs with Node.js, Nest.js and Python, built for real traffic.