Why Your Database Runs Out of Connections on Vercel
A serverless function instance cannot share a connection pool with the next one. Understanding what that means is the difference between a driver choice and a recurring incident.
The error arrives after a deploy that went fine, usually under the first burst
of traffic: too many connections, or Timed out fetching a new connection from the connection pool. The database is idle. The application is not doing
anything unusual. The same code has been running locally for months.
What changed is how many copies of it exist.
The thing that is actually different
In development, next dev is one Node process. You create a database client
once, it opens a pool, and every request in your application shares it. That is
the model every connection-pooling tutorial assumes, and it is correct.
Deployed to a serverless platform, your route handler runs inside a function instance. Each instance has its own module scope, its own memory and therefore its own pool. Ten warm instances holding a pool of ten is a hundred connections, and nothing in your code says the number a hundred.
The count is not driven by concurrent requests. It is driven by how many instances the platform has warm, which is a number you do not control and cannot see from inside the application.
The mistake that makes it immediate
// app/api/orders/route.ts
export async function GET() {
const client = new PrismaClient(); // ← a new pool, per request
...
}A client constructed inside a handler opens a pool on every invocation and leaves it to garbage collection. This exhausts a database in minutes under any real traffic.
The instance-level fix is to construct it once at module scope, with the development caveat that hot reloading re-evaluates modules and would otherwise accumulate clients:
// lib/db.ts
import { PrismaClient } from '@prisma/client';
const globalForDb = globalThis as unknown as { db?: PrismaClient };
export const db = globalForDb.db ?? new PrismaClient();
if (process.env.NODE_ENV !== 'production') globalForDb.db = db;That is necessary and it is not sufficient. It caps the pool per instance. It cannot cap the number of instances.
The three real answers
1. A pooler in front of the database. PgBouncer, or whatever your provider calls theirs. Your functions connect to it; it multiplexes them onto a small number of real backends. This is the standard answer and the one to reach for first.
The part to get right is the mode. Transaction pooling is what produces the connection numbers, and it hands you a backend only for the length of a transaction - so anything that spans statements stops working. In practice that means server-side prepared statements, which most ORMs use by default. Every provider documents a flag for this; set it deliberately rather than discovering it.
Keep a second, direct connection string for migrations. Schema changes need a real session, and running them through a transaction pooler fails in ways that are hard to read.
2. A driver that does not hold a connection at all. Some providers expose their database over HTTP, so a query is a request and there is nothing to pool:
import { neon } from '@neondatabase/serverless';
const sql = neon(process.env.DATABASE_URL!);
const rows = await sql`select id, total from orders limit 20`;This is the cleanest fit for the runtime model, and it is the only thing that works in an edge runtime with no TCP sockets. The trade is that a one-shot HTTP query is not a session - interactive transactions need the WebSocket path, and a handler issuing six sequential queries pays six round trips where a pooled connection paid one.
Use it for read paths with one or two queries. Use a pooled connection for anything transactional.
3. Fewer connections, because fewer queries. Underrated and usually available. A page doing one query instead of four needs a quarter of the connection-time. Fetching in a Server Component rather than through a route handler your client calls removes a hop entirely. Caching a query that changes hourly removes it from the hot path.
The fastest connection is the one nobody opens.
Where the queries live, which is the Next.js part
The App Router gives you several places a query can run, and they have different connection behaviour.
Server Components run during the render, on the server, and can query directly. Two sibling components each fetching means two queries in the same invocation - fine for a pool, worse for an HTTP driver.
Route Handlers are the conventional case and behave like any endpoint.
Server Actions run per submission. An action that writes wants a real transaction, which is the case where the HTTP driver's limitation matters.
Middleware runs on every matched request, before anything else. Querying from it multiplies your connection pressure by your traffic, including on requests that would have been served from cache - and it is expensive for other reasons too.
If you self-host
Most of this disappears. One long-running Node process means one module scope, one pool, and a connection count you set and can reason about. You are back to the model every Node article since 2015 describes.
That is a genuine point in favour of running it yourself, and it is a small one compared to what you take on. Mention it in the trade-off; do not choose a deployment model over it.
The diagnosis, in order
- Ask the database how many connections exist and where they are from, rather than inferring it.
- Confirm the client is constructed once per module, not per request.
- Confirm you are connecting to the pooler, and that migrations are not.
- Check whether prepared statements are disabled where transaction pooling requires it.
- Only then raise the limit - and write down what the new number was based on, because the next person to add a route will need to know a budget exists.
