FATAL: Too Many Clients Already: Connection Pooling for Vibe Projects
FATAL: too many clients already is not a slow query or the wrong database — it's connection exhaustion. This guide covers the pooling math, PgBouncer's three modes, Supabase/Neon pooler setup, the Prisma prepared-statement gotcha, and a troubleshooting table.

Scope first: what this guide does not cover
The database topic is crowded on this site, so here is the division of labor:
- "Choosing a database for vibe projects" answers which database — Postgres vs MySQL vs SQLite vs Mongo;
- "Slow queries and indexing" answers how to make SQL fast — explain, indexes, N+1;
- "Serverless cost optimization" answers how to cut the cloud bill — functions, bandwidth, invocations;
- "Background jobs and queues" answers how to do async — BullMQ, retries, dead letters.
This guide answers exactly one question: what to do when you run out of connections. Not slow SQL, not the wrong database — FATAL: too many clients already. The app can't get a connection and requests fail outright. It's the mandatory pit stop between "it runs" and "it scales" for vibe projects.
How expensive is one connection, really
PostgreSQL uses a process model: every connection is a backend process that sits in memory. An idle connection eats roughly 5–10MB (work_mem, buffer share), so 100 connections approach 1GB. Supabase's free tier defaults max_connections to around 60, Neon's free tier lower — while on your side, Prisma's default pool plus 2 serverless instances plus a background worker blows past 60 without trying.
Serverless makes it worse: Vercel / Cloudflare Workers can open a new connection per incoming request, so connections grow like a storm under load. Databases don't die from being queried to death — they die from being connected to death.
Do the math: how far are you from the wall
The formula is simple:
connections needed ≈ instances × pool size per instance + worker connections + manual ops connections
Example: Next.js on 3 instances (Prisma default pool 10) + 1 worker (pool 5) + 2 for your local psql = 3×10 + 5 + 2 = 37. Looks safe against 60? Under serverless, "instances" is elastic — a promo spike makes it 20 instances, 20×10 = 200, straight into the wall.

Pooling decouples two numbers: how many connections the app thinks it has versus how many the database really has. The app can hold 200 logical connections while the pool keeps 25 physical ones, reused by queries that actually need to run.
Three pooling layers: where do you intervene
| Layer | What | For whom |
|---|---|---|
| In-app pool | The pool inside Prisma / Drizzle / SQLAlchemy | Everyone — first line of defense, but can't tame many instances |
| PgBouncer (self-hosted) | Standalone pooling middleware between app and DB | Own VPS / Docker deploys, some ops capacity |
| Cloud pooler | Supabase (Supavisor) / Neon pooled connection strings | BaaS users — zero ops, the first choice |
Solo-dev priority order: cloud pooler first → self-hosted PgBouncer if that's not enough → in-app pool always on. The layers stack; they don't exclude each other.
PgBouncer's three modes: pick wrong and the pool is decoration
This is the most important decision table in the guide — the wrong mode makes pooling useless:
| Mode | Connection returned when | Use when | Forbidden |
|---|---|---|---|
| session | Client disconnects | Long-lived connections, LISTEN/NOTIFY, advisory locks | Serverless short connections: near-zero pooling benefit |
| transaction | Each transaction ends | Default for web apps, mandatory for serverless | No prepared statements, no LISTEN/NOTIFY, no DB switching mid-transaction |
| statement | Each statement ends | Rarely; multi-statement transactions just fail | Incompatible with nearly every ORM |
One-line memory aid: for a vibe project's web service, pick transaction mode without thinking. The price is giving up prepared statements — which is exactly the root of the famous Supabase gotcha below.
Path A: Supabase / Neon built-in pooler (zero-ops route)
Supabase's connection panel shows two strings: direct (port 5432) and Pooler (port 6543, Supavisor, transaction mode). Neon likewise offers a pooled string. Solo rule: the app always uses the pooler port; the direct port is reserved for migrations (prisma migrate) and your local psql.
The must-know Prisma gotcha — transaction mode doesn't support prepared statements, and Prisma enables them by default. Turn them off:
// schema.prisma
datasource db {
provider = "postgresql"
url = env("DATABASE_URL") // 6543 pooler, for runtime
directUrl = env("DIRECT_DATABASE_URL") // 5432 direct, migrations only
}
// prisma client setup
import { PrismaClient } from '@prisma/client';
import { PrismaPg } from '@prisma/adapter-pg';
import { Pool } from 'pg';
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const adapter = new PrismaPg(pool, {
// Critical: disable prepared statements under transaction pooling,
// otherwise: prepared statement "s0" does not exist
});
export const prisma = new PrismaClient({ adapter });
On older prisma-client-js without adapters, append to the connection string: ?pgbouncer=true&connection_limit=5 and Prisma disables prepared statements automatically. Drizzle (node-postgres driver) is simpler: new Pool({ connectionString }) against 6543 — node-postgres doesn't use prepared statements by default.
Neon users: the same logic applies to Neon's pooled connections. Note that Neon's free-tier compute auto-suspend makes the first request slow — that's cold start, a different topic, don't confuse it with pooling.
Path B: self-hosted PgBouncer (one docker compose service)
On a VPS with Docker Compose, it's one more service:
services:
pgbouncer:
image: edoburu/pgbouncer:1.22
restart: unless-stopped
environment:
DATABASES_HOST: db
DATABASES_PORT: 5432
DATABASES_USER: postgres
DATABASES_PASSWORD_FILE: /run/secrets/db_password
DATABASES_DBNAME: appdb
POOL_MODE: transaction # default to transaction
MAX_CLIENT_CONN: 200 # logical connections from apps
DEFAULT_POOL_SIZE: 25 # physical connections to PG
MIN_POOL_SIZE: 5
RESERVE_POOL_SIZE: 5 # burst buffer
RESERVE_POOL_TIMEOUT: 3
STATS_USERS: postgres
secrets: [db_password]
ports: ["127.0.0.1:6432:5432"] # app-only on localhost, never public
depends_on:
db: { condition: service_healthy }
Point DATABASE_URL at postgres://postgres:xxx@pgbouncer:5432/appdb (service name over the compose network). Only two knobs matter: DEFAULT_POOL_SIZE (physical connections — ~10MB each, so 25 leaves headroom on 1GB RAM) and MAX_CLIENT_CONN (logical cap — be generous, 200, so apps queue instead of failing).
Verify the pool is working: connect to the admin DB with psql -h 127.0.0.1 -p 6432 pgbouncer and run SHOW POOLS; — cl_active is logical app connections, sv_active is real physical ones. Under load test, sv_active pinned at 25 means pooling is doing its job.
In-app pool size: a one-line formula
The in-app pool (Prisma connection_limit, SQLAlchemy pool_size) shouldn't be huge either. Rule of thumb:
pool size per instance = CPU cores × 2 + effective disks (usually 1 on a single box)
On a 2-core VPS: pool size 5. Connections beyond that just queue while holding memory. The classic vibe-project mistake is "default config everywhere" — Prisma's default is num_cpus * 2 + 1, which is 17 on an 8-core dev machine; overriding it to 20 via env var on a 1-core VPS is pure waste. Set connection_limit=5 explicitly and let the pooler schedule the rest.
Troubleshooting: match your error to the fix
| Error | Root cause | Fix |
|---|---|---|
FATAL: too many clients already | Physical connections exceeded max_connections | Add PgBouncer / cloud pooler; check for leaked connections (borrowed, never returned) |
prepared statement "s0" does not exist | Prepared statements under transaction mode | Prisma: ?pgbouncer=true or adapter mode; Drizzle: check prepare flags |
server closed the connection unexpectedly | Cloud DB killed idle connections | Set in-app idleTimeout below the cloud timeout; add connection health checks |
Occasional timeout at peak, connections not full | Pool queuing: logical connections waiting for physical ones | Check cl_waiting in SHOW POOLS; raise DEFAULT_POOL_SIZE or fix slow queries |
Monitoring: two SQL queries to watch connections
-- connection counts by state: spot leaks at a glance
SELECT state, count(*) FROM pg_stat_activity GROUP BY state;
-- who's holding connections hostage (idle-in-transaction, longest first)
SELECT pid, usename, application_name, state,
now() - state_change AS idle_for, query
FROM pg_stat_activity
WHERE state = 'idle in transaction'
ORDER BY idle_for DESC LIMIT 10;
idle in transaction is suspect #1: a transaction opened and never committed, holding its connection. The common ORM pattern is calling an external API inside prisma.$transaction — one slow network call pins the connection. Fix: only DB operations inside transactions, external calls outside.
Alert threshold: page at 70% of max_connections, not 100%. HTTP-200 uptime monitoring isn't enough — add a minutely cron running the first query and notify past the threshold.
Pre-launch checklist
- ☐ App uses the pooler port (6543/6432); direct port reserved for migrations and manual ops
- ☐ Transaction mode + prepared statements disabled (Prisma: mandatory check)
- ☐
SHOW POOLSconfirmssv_activepinned at the expected value - ☐ Per-instance
connection_limitset explicitly per the formula — not oversized - ☐ No external API calls inside transactions (prevents
idle in transactionleaks) - ☐ 70%-of-max-connections alert configured
- ☐ Load tested: 5× traffic simulated, queuing controlled, no too-many-clients
One line to close: connection count is the cheapest scaling bottleneck a vibe project has — not more machines, one more pooling layer. Get the math right, pick the right mode, disable prepared statements, and 80% of "the database can't keep up" ends here.
Comments (0)
Related articles

When append-only tables outgrow RAM, backups and queries decay together. A field guide to archiving for vibe projects: four signals that it's time, three strategies compared (native partitioning + pg_partman vs archive tables vs Parquet cold export), an idempotent archive job state machine, query routing, thaw procedures, and how archiving relates to backups and compliance retention.

A prompt is a suggestion, not a constraint. Four defensive layers for reliable AI structured output: native structured outputs (OpenAI vs Anthropic, verified against docs), zod validation with the strict/passthrough tradeoff, repair retries that feed raw errors back, and degraded fallbacks chosen by cost.

Feature flags decouple deploy from release: code ships first, features turn on later. Four flag types and a selection matrix, a one-table minimal implementation, the fallback iron rule (new features fail-closed, kill-switches freeze), flag-rot governance, and how flags pair with canary releases.