Skip to content

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.

6 min read

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

  1. Ask the database how many connections exist and where they are from, rather than inferring it.
  2. Confirm the client is constructed once per module, not per request.
  3. Confirm you are connecting to the pooler, and that migrations are not.
  4. Check whether prepared statements are disabled where transaction pooling requires it.
  5. 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.

Which database to run at all sits upstream of this, and the connection model is most of that answer. Prisma or Drizzle sits beside it, and decides whether disabling prepared statements is a configuration flag or a rewrite.

The same fact about instance memory shows up in a nastier form when the thing being cached is a credential rather than a connection, which is what makes an OAuth integration misbehave in production.

Related questions

Why does this not happen in development?
Because one process serves every request. The pool you created is shared by all of them, and it is bounded, and it works exactly as designed. Deployed, each instance gets its own module scope and its own pool, and the count becomes a function of how many instances are warm rather than how many requests are in flight.
Does the edge runtime change anything?
It removes the question by removing the option. There are no TCP sockets there, so a conventional Postgres driver cannot run at all - only a driver that speaks HTTP or WebSockets. That is a constraint worth knowing before you add `export const runtime = "edge"` to a route that queries.
Is a pooler enough on its own?
It fixes the count and introduces a mode. Transaction pooling is what gives you the numbers, and it breaks anything spanning statements - which for most Next.js stacks means server-side prepared statements, and that is a connection-string flag rather than a rewrite. Session pooling keeps everything and gives back much less.
Does self-hosting avoid all of this?
Largely, yes, and it is the clearest practical difference between the two deployment models. A long-running Node process has one module scope, so one pool, and the ordinary advice from a decade of Node applications applies unchanged.

← Back to all articles

Call us+1 848 272 7583WhatsApp+90 850 308 5436Emailinfo@codefacture.comContact page