web server (3 replicas x 10 connections) = 30 connections
background worker (3 replicas x 10 connections) = 30 connections
job scheduler (3 replicas x 5 connections) = 15 connections
Total: 75 connections at idle
web server (3 replicas x 10 connections) = 30 connections
background worker (3 replicas x 10 connections) = 30 connections
job scheduler (3 replicas x 5 connections) = 15 connections
Total: 75 connections at idle
web server (3 replicas x 10 connections) = 30 connections
background worker (3 replicas x 10 connections) = 30 connections
job scheduler (3 replicas x 5 connections) = 15 connections
Total: 75 connections at idle
Error: remaining connection slots are reserved for non-replication superuser connections
Error: remaining connection slots are reserved for non-replication superuser connections
Error: remaining connection slots are reserved for non-replication superuser connections
App (100 client connections) | [PgBouncer] |
PostgreSQL (20 server connections)
App (100 client connections) | [PgBouncer] |
PostgreSQL (20 server connections)
App (100 client connections) | [PgBouncer] |
PostgreSQL (20 server connections)
# docker-compose.yml
services: pgbouncer: image: bitnami/pgbouncer:latest environment: POSTGRESQL_HOST: postgres POSTGRESQL_PORT: 5432 POSTGRESQL_DATABASE: myapp POSTGRESQL_USERNAME: app_user POSTGRESQL_PASSWORD: ${DB_PASSWORD} PGBOUNCER_PORT: 6432 PGBOUNCER_POOL_MODE: transaction PGBOUNCER_MAX_CLIENT_CONN: 1000 PGBOUNCER_DEFAULT_POOL_SIZE: 25 PGBOUNCER_MIN_POOL_SIZE: 5 PGBOUNCER_RESERVE_POOL_SIZE: 5 PGBOUNCER_RESERVE_POOL_TIMEOUT: 3 PGBOUNCER_SERVER_IDLE_TIMEOUT: 600 ports: - "6432:6432" depends_on: - postgres
# docker-compose.yml
services: pgbouncer: image: bitnami/pgbouncer:latest environment: POSTGRESQL_HOST: postgres POSTGRESQL_PORT: 5432 POSTGRESQL_DATABASE: myapp POSTGRESQL_USERNAME: app_user POSTGRESQL_PASSWORD: ${DB_PASSWORD} PGBOUNCER_PORT: 6432 PGBOUNCER_POOL_MODE: transaction PGBOUNCER_MAX_CLIENT_CONN: 1000 PGBOUNCER_DEFAULT_POOL_SIZE: 25 PGBOUNCER_MIN_POOL_SIZE: 5 PGBOUNCER_RESERVE_POOL_SIZE: 5 PGBOUNCER_RESERVE_POOL_TIMEOUT: 3 PGBOUNCER_SERVER_IDLE_TIMEOUT: 600 ports: - "6432:6432" depends_on: - postgres
# docker-compose.yml
services: pgbouncer: image: bitnami/pgbouncer:latest environment: POSTGRESQL_HOST: postgres POSTGRESQL_PORT: 5432 POSTGRESQL_DATABASE: myapp POSTGRESQL_USERNAME: app_user POSTGRESQL_PASSWORD: ${DB_PASSWORD} PGBOUNCER_PORT: 6432 PGBOUNCER_POOL_MODE: transaction PGBOUNCER_MAX_CLIENT_CONN: 1000 PGBOUNCER_DEFAULT_POOL_SIZE: 25 PGBOUNCER_MIN_POOL_SIZE: 5 PGBOUNCER_RESERVE_POOL_SIZE: 5 PGBOUNCER_RESERVE_POOL_TIMEOUT: 3 PGBOUNCER_SERVER_IDLE_TIMEOUT: 600 ports: - "6432:6432" depends_on: - postgres
// Before
const pool = new Pool({ connectionString: "postgresql://app_user:password@postgres:5432/myapp", max: 10,
}); // After
const pool = new Pool({ connectionString: "postgresql://app_user:password@pgbouncer:6432/myapp", max: 25, // can be higher now - PgBouncer handles the real limit
});
// Before
const pool = new Pool({ connectionString: "postgresql://app_user:password@postgres:5432/myapp", max: 10,
}); // After
const pool = new Pool({ connectionString: "postgresql://app_user:password@pgbouncer:6432/myapp", max: 25, // can be higher now - PgBouncer handles the real limit
});
// Before
const pool = new Pool({ connectionString: "postgresql://app_user:password@postgres:5432/myapp", max: 10,
}); // After
const pool = new Pool({ connectionString: "postgresql://app_user:password@pgbouncer:6432/myapp", max: 25, // can be higher now - PgBouncer handles the real limit
});
// This does NOT use a persistent prepared statement - works fine with PgBouncer
await client.query("SELECT * FROM users WHERE id = $1", [userId]); // This DOES use a persistent prepared statement (the `name` property) - breaks with PgBouncer
await client.query({ name: "get-user-by-id", text: "SELECT * FROM users WHERE id = $1", values: [userId],
});
// This does NOT use a persistent prepared statement - works fine with PgBouncer
await client.query("SELECT * FROM users WHERE id = $1", [userId]); // This DOES use a persistent prepared statement (the `name` property) - breaks with PgBouncer
await client.query({ name: "get-user-by-id", text: "SELECT * FROM users WHERE id = $1", values: [userId],
});
// This does NOT use a persistent prepared statement - works fine with PgBouncer
await client.query("SELECT * FROM users WHERE id = $1", [userId]); // This DOES use a persistent prepared statement (the `name` property) - breaks with PgBouncer
await client.query({ name: "get-user-by-id", text: "SELECT * FROM users WHERE id = $1", values: [userId],
});
// Direct connection for LISTEN/NOTIFY, bypassing PgBouncer
const notifyClient = new Client({ connectionString: process.env.DATABASE_DIRECT_URL, // points to :5432
});
await notifyClient.connect();
await notifyClient.query("LISTEN log_events");
// Direct connection for LISTEN/NOTIFY, bypassing PgBouncer
const notifyClient = new Client({ connectionString: process.env.DATABASE_DIRECT_URL, // points to :5432
});
await notifyClient.connect();
await notifyClient.query("LISTEN log_events");
// Direct connection for LISTEN/NOTIFY, bypassing PgBouncer
const notifyClient = new Client({ connectionString: process.env.DATABASE_DIRECT_URL, // points to :5432
});
await notifyClient.connect();
await notifyClient.query("LISTEN log_events");
# Without this flag, Prisma breaks silently with PgBouncer/Supavisor in transaction mode
DATABASE_URL="postgresql://user:password@pgbouncer:6432/myapp?pgbouncer=true"
# Without this flag, Prisma breaks silently with PgBouncer/Supavisor in transaction mode
DATABASE_URL="postgresql://user:password@pgbouncer:6432/myapp?pgbouncer=true"
# Without this flag, Prisma breaks silently with PgBouncer/Supavisor in transaction mode
DATABASE_URL="postgresql://user:password@pgbouncer:6432/myapp?pgbouncer=true"
-- See current value
SHOW max_connections; -- See current active connections
SELECT count(*) FROM pg_stat_activity;
-- See current value
SHOW max_connections; -- See current active connections
SELECT count(*) FROM pg_stat_activity;
-- See current value
SHOW max_connections; -- See current active connections
SELECT count(*) FROM pg_stat_activity;
max_connections = (pool_size * number_of_pools) + reserved_superuser_connections
max_connections = (pool_size * number_of_pools) + reserved_superuser_connections
max_connections = (pool_size * number_of_pools) + reserved_superuser_connections
max_connections = 25 + 10 (headroom) = 35
max_connections = 25 + 10 (headroom) = 35
max_connections = 25 + 10 (headroom) = 35
max_connections = 35
shared_buffers = 256MB # ~25% of available RAM
work_mem = 16MB # per sort/hash operation, per connection
max_connections = 35
shared_buffers = 256MB # ~25% of available RAM
work_mem = 16MB # per sort/hash operation, per connection
max_connections = 35
shared_buffers = 256MB # ~25% of available RAM
work_mem = 16MB # per sort/hash operation, per connection - RDS: RDS Proxy is AWS's managed connection pooler. It's PgBouncer-like, works in transaction mode, integrates with IAM authentication. It costs extra ($0.015/vCPU-hour) but removes the operational burden.
- Supabase: Has a built-in connection pooler called Supavisor (which replaced their PgBouncer setup in 2023) working in transaction mode on port 6543. Use that URL for your application instead of the direct connection string.
- Neon: Serverless pooling built-in, similar to transaction mode.
- PlanetScale: MySQL-based, different story entirely. - Is your pool size per process configured explicitly, or defaulting to 10?
- How many processes/replicas connect to the database? What's the total connection count?
- Are you within 80% of max_connections at peak?
- Do you have PgBouncer or equivalent in front of PostgreSQL?
- Are you using set_config for RLS context rather than SET statements?
- Are you using pg_advisory_xact_lock instead of pg_advisory_lock?
- Do you have a dedicated connection for LISTEN/NOTIFY that bypasses the pool?