engineering//4 min read
Why Postgres runs out of connections on serverless, and how to fix it
“Too many clients” and “max clients reached” are rarely a database problem. They are a connection-management problem, and they show up first in dev and on serverless. Here is what is happening and the four fixes that make it go away.

In short
- Every Postgres connection is a server process with its own memory, so connections are a scarce resource, not a free one.
- Serverless platforms and dev servers multiply connection pools: one per instance, and one per hot reload if you are not careful.
- Keep one small pool per process, close idle connections, and reuse the client across hot reloads in development.
- For serverless traffic, connect through a transaction-mode pooler and turn off prepared statements in the driver.
The error arrives without warning. The site worked an hour ago; now every page that touches the database fails with something like remaining connection slots are reserved or, on Supabase, (EMAXCONNSESSION) max clients reached in session mode. Nothing in the code changed, and the database is barely busy. What happened?
Almost always, the database is fine. It has simply run out of connections, and the reason is in how the application opens them.
A connection is not free
Postgres handles each client connection with its own server process. Each one holds memory for sorting, caching and session state whether it is working or idle. That is why every Postgres server has a max_connections setting, and why managed providers cap it, often at a few dozen to a few hundred depending on the plan. Poolers in front of the database add a second cap of their own.
A connection that sits idle still counts. So the question is not how many queries you run, but how many connections are open.
Where the extra connections come from
Serverless multiplies pools
A traditional server process opens a pool of, say, ten connections and shares them across every request. On a serverless platform, each warm function instance is its own process with its own pool. Fifty concurrent instances with a pool of ten is five hundred connections, even if each instance only ever uses one at a time.
Hot reload leaks pools in development
This one surprises people because it happens on a laptop. In a typical Next.js project the database client is created at module level:
import postgres from "postgres";
import { drizzle } from "drizzle-orm/postgres-js";
const client = postgres(process.env.DATABASE_URL!); // default pool: 10
export const db = drizzle(client);Every time you save a file, the dev server re-evaluates that module and creates a new pool. The old pool is never closed, so its connections stay open. A morning of editing can hold dozens of connections, and on a pooler limited to 15 clients, the whole app (including production, if it shares the database) stops.
Fix 1: one small pool per process
Reuse the client across hot reloads by keeping it on globalThis in development, and keep the pool small. A web request rarely needs more than one connection at a time.
import postgres from "postgres";
import { drizzle } from "drizzle-orm/postgres-js";
const globalForDb = globalThis as unknown as {
pgClient?: ReturnType<typeof postgres>;
};
const client =
globalForDb.pgClient ??
postgres(process.env.DATABASE_URL!, {
max: 5, // a small pool is plenty for one process
idle_timeout: 20, // seconds: give idle connections back
connect_timeout: 15,
});
if (process.env.NODE_ENV !== "production") globalForDb.pgClient = client;
export const db = drizzle(client);Fix 2: close idle connections
An idle_timeout lets a quiet instance hand its connections back instead of holding them until the platform freezes it. On serverless this matters more than pool size: frozen instances can keep sockets open far longer than you expect.
Fix 3: use a transaction-mode pooler for serverless
A pooler such as PgBouncer or Supabase's Supavisor sits between your app and Postgres. In session mode it gives each client a real database connection for as long as the client stays connected, which protects the database but does not stretch it. In transaction mode a real connection is lent out only for the length of one transaction, then returned, so hundreds of short-lived clients can share a handful of database connections.
Session mode | Transaction mode | |
|---|---|---|
Connection held for | The whole client session | One transaction |
Good for | Long-lived servers, migrations | Serverless and edge functions |
Prepared statements | Work as normal | Turn them off in the driver |
Session features (SET, LISTEN, advisory locks) | Work | Do not carry across transactions |
On Supabase the pooler's session mode listens on port 5432 and transaction mode on 6543. Because a transaction-mode connection may be a different backend on every query, disable prepared statements: with postgres.js that is postgres(url, { prepare: false }). Run migrations through session mode or a direct connection, since they often rely on session state.
Fix 4: watch the number
Know what is holding connections before it becomes an outage:
select usename, application_name, state, count(*)
from pg_stat_activity
where datname = current_database()
group by 1, 2, 3
order by count(*) desc;Many rows in state idle from the same application are the signature of leaked or oversized pools. Many in idle in transaction point at code that opens a transaction and forgets to finish it.
If the database is quiet but out of connections, look at the clients, not the server.
A short checklist
- To do: One database client per process, reused across hot reloads in development
- To do: Pool size of 5 or fewer per serverless instance
- To do: An idle timeout on the pool
- To do: Serverless traffic through a transaction-mode pooler, with prepared statements off
- To do: Migrations through session mode or a direct connection
- To do: An alert when connections pass 80% of the limit

