Supabase Connection Pool Exhausted: Fix the Serverless Anti-Pattern

`remaining connection slots are reserved` and `too many clients already` — switch serverless to the port 6543 pooler, the right way.

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 logsMost likely causeJump to
too many clients already under traffic spikesServerless on direct :5432, no poolerCause 1 / Step 2
Connection count climbs and never dropsLeaked connections (no release())Cause 2 / Step 5
prepared statement "s0" already exists / ...does not existPrepared statements under transaction poolingCause 4 / Step 4
MaxClientsInSessionMode / “max clients reached” from the poolerPooler client limit hit, or session-mode misuseCause 3
Errors cluster at the top of the hourA cron / batch job hogging the poolCause 5
pg_stat_activity shows pgAdmin/Metabase/DBeaverA GUI tool parked on prodCause 6
Can’t connect at all from a new host (ENETUNREACH, IPv6 address)Direct connection needs IPv6; you’re on an IPv4-only networkSee “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 with ENETUNREACH or “no route to host” before you ever hit a pool limit.
  • The Shared Pooler / Supavisor (...pooler.supabase.com, both 5432 and 6543) 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

  1. After deploying the :6543 string, run a load test (or just refresh under real traffic) and watch SELECT state, count(*) FROM pg_stat_activity GROUP BY state; — the active count should plateau well under your cap instead of climbing.
  2. Confirm your connection string host ends in pooler.supabase.com and the port is 6543 in your deployed env (check the platform’s env-var dashboard, not just your local .env).
  3. 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 :6543 transaction 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=1 per instance and pgbouncer=true for Prisma.
  • Every query: await plus a try/finally release, or just use pool.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.

Tags: #Indie dev #Debug #Troubleshooting