Your Vercel / Cloudflare / Netlify functions all return 500 at once, and the logs are full of one of these:
error: remaining connection slots are reserved for non-replication
superuser and rds_superuser connections
or:
sorry, too many clients already
That’s Supabase (really plain Postgres) connection-pool exhaustion.
Fastest fix: point your serverless code at the transaction-mode pooler on port 6543 (...pooler.supabase.com:6543), not the direct :5432 string. Then make sure every query releases its connection. The two steps below (Step 1 + Step 2) recover a downed service in a couple of minutes.
Why this happens: a Postgres connection is heavy (each holds roughly 5-10 MB of server RAM and a backend process), nothing like a cheap HTTP request. Your Postgres instance only accepts a fixed number of direct connections — on Supabase’s Free tier that’s 60 (it scales with compute size: Micro 60, Small 90, Medium 120, and up, as of June 2026). A serverless platform under a traffic burst can spin up 100+ isolated instances, each opening its own direct connection, and you blow past the cap instantly. The fix is not “buy more connections” — it’s to put a connection pooler (Supabase’s built-in Supavisor) between the functions and Postgres so thousands of short-lived clients share a small set of real backend connections.
Which bucket are you in?
| Symptom in logs | Most likely cause | Jump to |
|---|---|---|
too many clients already under traffic spikes | Serverless on direct :5432, no pooler | Cause 1 / Step 2 |
| Connection count climbs and never drops | Leaked connections (no release()) | Cause 2 / Step 5 |
prepared statement "s0" already exists / ...does not exist | Prepared statements under transaction pooling | Cause 4 / Step 4 |
MaxClientsInSessionMode / “max clients reached” from the pooler | Pooler client limit hit, or session-mode misuse | Cause 3 |
| Errors cluster at the top of the hour | A cron / batch job hogging the pool | Cause 5 |
pg_stat_activity shows pgAdmin/Metabase/DBeaver | A GUI tool parked on prod | Cause 6 |
Can’t connect at all from a new host (ENETUNREACH, IPv6 address) | Direct connection needs IPv6; you’re on an IPv4-only network | See “IPv4 vs IPv6” |
Common causes
Ordered by hit rate, highest first.
1. Serverless connecting directly on :5432 (no pooler)
By far the most common. The code uses the dashboard’s Direct connection string. Every Lambda / Worker / Edge instance opens its own backend connection. A burst spins up 100 instances, opens 100 connections, and trips the cap.
How to spot it: look at the host and port in your connection string. db.<ref>.supabase.co:5432 is the direct connection. aws-<region>.pooler.supabase.com:6543 is the transaction pooler. Serverless must use the second one.
2. Connections never released / long transactions
const client = await pool.connect();
const result = await client.query('SELECT ...');
// missing client.release()
Leak one connection per request and you exhaust the pool within hours. The same thing happens if you open a transaction (BEGIN) and an early return/throw skips the COMMIT/ROLLBACK — the backend sits in idle in transaction forever.
How to spot it: active connection count climbs monotonically and never falls back to baseline, even when traffic is idle.
3. Pooler client limit reached / session-mode misuse
Supavisor exposes two different strings. Port 6543 is transaction mode — connections are returned to the pool the moment each transaction ends, so a few backend connections serve thousands of clients. Port 5432 on the pooler.supabase.com host is session mode — each client holds a dedicated connection for its whole session, which gives you back full Postgres compatibility but defeats the scaling benefit. (Note: session mode on port 6543 was removed on Feb 28, 2025 — 6543 is transaction-only now, so you pick the mode by which port/string you copy, not by a pool_mode query parameter.)
How to spot it: the pooler returns MaxClientsInSessionMode or a “max clients reached” error, or you see a serverless workload pointed at the :5432 pooler string.
4. Statements the transaction pooler can’t pool
Prepared statements, LISTEN/NOTIFY, advisory locks, and session-scoped temp tables don’t survive transaction-mode pooling, because the next transaction may land on a different backend connection. ORMs are the usual trigger: Prisma, and node-postgres when you call pool.query with a name, create server-side prepared statements by default.
How to spot it: errors like prepared statement "s0" already exists or prepared statement "s1" does not exist.
5. Background cron / batch jobs running slow queries
A scheduled analytics query that runs for 30 seconds holds a connection the whole time. If several fire at once while normal traffic also needs connections, the pool gets eaten by the cron job.
How to spot it: errors cluster tightly around your cron trigger times (often :00 of the hour).
6. Side-channel tools (pgAdmin, Metabase, DBeaver) holding connections
You or a teammate point a GUI directly at the prod database. Each idle GUI session can hold 5-10 connections that never close.
How to spot it: run SELECT application_name, count(*) FROM pg_stat_activity GROUP BY application_name; and look for tool names you recognize.
IPv4 vs IPv6 (read this if you “can’t connect at all”)
This trips people up while they’re migrating off the direct string. As of June 2026:
- The direct connection (
db.<ref>.supabase.co:5432) resolves to an IPv6 address. If your runtime or network is IPv4-only (many CI runners, some corporate networks, older serverless regions), it fails withENETUNREACHor “no route to host” before you ever hit a pool limit. - The Shared Pooler / Supavisor (
...pooler.supabase.com, both5432and6543) is IPv4-compatible on every tier — another reason serverless should use it. - If you genuinely need a direct connection over IPv4, enable the paid IPv4 add-on, or use the Dedicated Pooler (co-located with your database, lower latency than the shared pooler, available on paid compute).
Shortest path to fix
Step 1: Emergency relief — kill stuck connections
Run this in the Supabase Dashboard SQL Editor to free connections immediately:
-- See what's actually connected and busy
SELECT pid, usename, application_name, state, query_start
FROM pg_stat_activity
WHERE state != 'idle'
ORDER BY query_start;
-- Terminate transactions stuck idle for more than 5 minutes
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE state = 'idle in transaction'
AND query_start < now() - interval '5 minutes';
This buys you breathing room so the service recovers, but it’s a tourniquet — Steps 2-5 are the actual fix.
Step 2: Switch serverless to the transaction pooler
In the Supabase Dashboard, click Connect (top bar) — the modal lists all three strings. (The same strings live under Project Settings → Database → Connection string, where a Connection pooling tab lets you tune pool settings.)
Direct (IPv6, long-running servers only):
postgresql://postgres:[PASSWORD]@db.<ref>.supabase.co:5432/postgres
Session pooler (port 5432, IPv4, dedicated connection per client):
postgres://postgres.<ref>:[PASSWORD]@aws-<region>.pooler.supabase.com:5432/postgres
Transaction pooler (port 6543, IPv4 — use this for serverless):
postgres://postgres.<ref>:[PASSWORD]@aws-<region>.pooler.supabase.com:6543/postgres
Note the username differs: direct uses postgres, both pooler strings use postgres.<ref>. Update your env var (DATABASE_URL) to the :6543 string and redeploy.
Step 3: Right-size the pool, not the plan
You don’t choose “session vs transaction” with a query parameter anymore — you choose it by copying the :6543 (transaction) or :5432 pooler (session) string. For serverless, you want transaction mode.
If a single function instance ever opens more than one connection, cap it so a thundering herd can’t each grab a handful:
postgres://...@...:6543/postgres?connection_limit=1
For serverless, connection_limit=1 per instance is the right default — each instance handles one request at a time. The pooler’s Pool Size (default 15 per tenant, tunable in the Connection pooling tab) is the number of real backend connections it keeps to Postgres; the Max Client Connections (200 default on Free, as of June 2026) is how many serverless clients can attach to the pooler at once.
Step 4: Prisma / ORM specifics (prepared statements)
Transaction mode can’t use server-side prepared statements, so Prisma needs pgbouncer=true (this tells Prisma to disable them):
// .env
// Runtime queries → transaction pooler, prepared statements off:
DATABASE_URL="postgres://postgres.<ref>:[PASSWORD]@aws-<region>.pooler.supabase.com:6543/postgres?pgbouncer=true&connection_limit=1"
// Migrations need a non-pooled, session-capable connection:
DIRECT_URL="postgres://postgres.<ref>:[PASSWORD]@aws-<region>.pooler.supabase.com:5432/postgres"
// schema.prisma
datasource db {
provider = "postgresql"
url = env("DATABASE_URL") // app queries → :6543 pooler
directUrl = env("DIRECT_URL") // prisma migrate / db push → :5432
}
Point directUrl at the session-mode pooler (:5432) rather than the raw direct string — the direct host needs IPv6, which many CI/migration environments don’t have. For node-postgres, avoid named prepared statements; for Drizzle’s postgres-js driver, set prepare: false.
Step 5: Always release connections
// Wrong — leaks on error
const client = await pool.connect();
await client.query(/* ... */);
// no finally → connection never returned
// Right — guaranteed release
const client = await pool.connect();
try {
await client.query(/* ... */);
} finally {
client.release();
}
// Simplest — pool.query checks out and returns the connection for you
await pool.query('SELECT * FROM users WHERE id = $1', [id]);
Only check out a client manually when you need a multi-statement transaction; otherwise prefer pool.query.
Step 6: Monitor and alert
-- Current connections broken down by state
SELECT state, count(*) FROM pg_stat_activity GROUP BY state;
-- Long-running idle-in-transaction (the leak signal)
SELECT pid, query, query_start, state
FROM pg_stat_activity
WHERE state = 'idle in transaction'
AND query_start < now() - interval '1 minute';
Set an alert when total connections exceed ~80% of your cap. Supabase’s dashboard also charts connection count under Reports → Database.
Step 7: Upgrade compute (last resort, not first)
The direct-connection cap scales with compute size, not plan name: roughly 60 (Micro/Free), 90 (Small), 120 (Medium), and higher on bigger instances, as of June 2026. The Pro plan ($25/month) lets you raise compute and pooler limits. Upgrading buys headroom, but if you’re on direct connections from serverless it only delays the next outage — fix the pooling first.
How to confirm it’s fixed
- After deploying the
:6543string, run a load test (or just refresh under real traffic) and watchSELECT state, count(*) FROM pg_stat_activity GROUP BY state;— the active count should plateau well under your cap instead of climbing. - Confirm your connection string host ends in
pooler.supabase.comand the port is6543in your deployed env (check the platform’s env-var dashboard, not just your local.env). - Let it sit idle for 10 minutes, then re-run the count query — idle-in-transaction should drop back near zero. If it doesn’t, you still have a
release()leak (Step 5).
Prevention
- Serverless / edge: always the
:6543transaction pooler; never the direct:5432. - Only long-running backends (Fly.io, Railway, a VM/EC2 process with a stable pool) should use the direct connection — and only over IPv6 or with the IPv4 add-on.
- With an ORM in serverless, set
connection_limit=1per instance andpgbouncer=truefor Prisma. - Every query:
awaitplus atry/finallyrelease, or just usepool.query. - Monitor connection count and alert above 80% of the cap.
- Give cron / batch jobs their own connection or a dedicated worker so they don’t compete with request traffic.
- Don’t let laptops or GUI tools sit on the prod database — use Supabase Studio, a read replica, or the Dedicated Pooler.
- For high-traffic projects, estimate connection needs on day one; Free’s 60 direct connections disappear fast.
FAQ
What’s the difference between port 5432 and 6543?
On the pooler.supabase.com host, 5432 is session mode (one dedicated connection per client, full Postgres features) and 6543 is transaction mode (connections recycled after each transaction, built for serverless). The db.<ref>.supabase.co:5432 host is a separate direct connection that bypasses the pooler entirely.
Should I ever raise connection_limit above 1 in serverless?
Rarely. Each serverless instance handles one request at a time, so one connection is enough. Raising it just multiplies how fast a traffic burst can saturate the pooler. Long-running servers with internal concurrency are the exception.
Why do I get prepared statement "s0" already exists?
Your ORM or driver is creating server-side prepared statements that don’t survive transaction-mode pooling. For Prisma, add ?pgbouncer=true to DATABASE_URL; for other drivers, disable prepared statements (e.g. prepare: false in postgres-js).
I switched to the pooler and now I can’t run migrations — why?
Transaction mode doesn’t support the session-level features migrations need (advisory locks, prepared statements). Run migrations through a separate session-mode connection — directUrl on the :5432 pooler host (not the IPv6 direct string).
Connection count is fine but I still see “too many clients” — what now?
You may be hitting the pooler’s Max Client Connections rather than the Postgres direct cap, or a GUI tool / cron is parked on the database. Check SELECT application_name, count(*) FROM pg_stat_activity GROUP BY application_name; and raise the pooler client limit in the Connection pooling tab if it’s legitimately app traffic.
Does the direct connection work from GitHub Actions / CI?
Often not — the direct host is IPv6-only and many CI runners are IPv4-only, so you’ll see ENETUNREACH. Use the :5432 session pooler string for CI/migrations, or enable the IPv4 add-on.
Related
Tags: #Indie dev #Debug #Troubleshooting