Why Your Next.js App Keeps Hitting "Too Many Connections" in Production
A practical breakdown of why serverless Next.js apps exhaust PostgreSQL connections, and the three fixes that actually work — singleton clients, transaction-mode pooling, and HTTP drivers.
Why Your Next.js App Keeps Hitting "Too Many Connections" in Production
If you've deployed a Next.js App Router project to Vercel and pointed it at a plain PostgreSQL instance, you've probably already seen this in your logs:
FATAL: too many connections for role
It usually shows up the first time real traffic hits the app — not in dev, not in your first few Vercel previews, but the day someone shares the link and 40 people open it at once. The app worked fine right up until it didn't, and that timing is exactly why this bug is so easy to miss until it costs you.
Why serverless and PostgreSQL fight each other
A traditional Node server holds one connection pool for its entire lifetime — 10 or 20 TCP connections, reused across every request. Serverless breaks that assumption. Every incoming request can spin up its own isolated function instance, and each of those instances tries to open its own direct connection to Postgres.
Send 500 concurrent requests and you don't get 500 requests sharing 20 connections. You get up to 500 attempts to open 500 separate connections. Most managed Postgres tiers cap max_connections around 100, so you blow past the ceiling well before you're anywhere near "real" scale — this isn't a 10k-users problem, it's a mid-traffic-Tuesday problem.
There's a second, quieter version of the same issue in local development: every hot reload re-instantiates PrismaClient or drizzle(pool), and each fresh instance opens its own pool without closing the last one. Your dev server just slowly accumulates zombie connections until Postgres starts rejecting new ones — annoying, but at least it's contained to your machine.
Fix one: stop leaking connections with a global singleton
This doesn't solve the serverless scaling problem, but skip it and you'll be debugging two problems at once instead of one. The idea is simple: Node.js keeps globalThis alive across warm invocations, so attach your client to it instead of letting each module re-import spin up a fresh one.
Prisma:
// src/lib/prisma.ts
import { PrismaClient } from '@prisma/client';
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
});
if (process.env.NODE_ENV !== 'production') {
globalForPrisma.prisma = prisma;
}
Drizzle, with one addition that matters more than the singleton itself — capping max at 1:
// src/lib/db.ts
import { drizzle } from 'drizzle-orm/node-postgres';
import { Pool } from 'pg';
import * as schema from './schema';
const globalForDb = globalThis as unknown as {
pool: Pool | undefined;
};
const pool =
globalForDb.pool ??
new Pool({
connectionString: process.env.DATABASE_URL,
max: 1, // one connection per serverless container, not a shared local pool
idleTimeoutMillis: 10_000,
connectionTimeoutMillis: 5_000,
});
if (process.env.NODE_ENV !== 'production') {
globalForDb.pool = pool;
}
export const db = drizzle(pool, { schema });
Setting max: 1 looks wrong if you're used to thinking about pool sizing for a stateful server, but that's the point — in serverless, the "pool" isn't per-app, it's per-container. One connection per container is the ceiling you actually want, because a warm instance handling several sequential requests only needs one connection at a time anyway.
This gets you from "connections leak indefinitely" to "connections scale linearly with instances." That's progress, but it still doesn't cap the total. For that you need something in front of Postgres.
Fix two: put a transaction-mode pooler in front of Postgres
Direct database connections shouldn't be reachable from your lambdas at all. Put PgBouncer, Supabase's Supavisor, or AWS RDS Proxy between your app and Postgres, and let that layer absorb the connection churn instead of the database itself.
The mode you pick here isn't a minor config detail — it's the whole point:
- Session mode holds a real database connection open for as long as the client session lasts. In serverless, where a "session" might be a single request, this offers basically no benefit over connecting directly.
- Transaction mode holds the connection only for the duration of one SQL transaction, then returns it to the pool the instant the query resolves. This is what actually lets thousands of short-lived serverless connections share a small number of real database connections.
For Prisma, that means two different connection strings — one through the pooler for app traffic, one direct for migrations, since schema changes need a real session:
# Point to PgBouncer's port (usually 6543), pgbouncer=true tells Prisma to disable prepared statements
DATABASE_URL="postgresql://user:password@aws-pooler.region.rds.amazonaws.com:6543/dbname?pgbouncer=true&connection_limit=1"
# Direct connection, used only for migrations and schema pushes (port 5432)
DIRECT_URL="postgresql://user:password@aws-primary.region.rds.amazonaws.com:5432/dbname"
Once this is in place, the math flips. Instead of connection count scaling with concurrent instances, it scales with pooler capacity — 10,000 concurrent lambdas can share 20–50 real Postgres connections, because none of them are holding a connection open longer than it takes to run one query.
Where this actually lands: the four architectures compared
| Architecture | Concurrent Serverless Instances | Max DB Connections Required | Cold Start Latency |
|---|---|---|---|
| Direct connections, no pooler | 500 instances | 500 connections (DB crashes) | High (~300ms TLS handshake) |
| ORM singleton only | 500 instances | 500 connections (crash delayed, not avoided) | Moderate |
| Transaction-mode PgBouncer / RDS Proxy | 10,000+ instances | 20–50 pooled connections | Low (~20ms connection reuse) |
| Serverless HTTP driver (Neon, Hyperdrive) | 50,000+ instances | Micro-pooled over HTTP/WebSockets | Instant |
The jump from row two to row three is the one that matters for most teams — it's the difference between "this breaks under real traffic" and "this doesn't break." Row four is worth knowing about even if you don't need it yet: providers like Neon query over HTTP or WebSockets instead of raw TCP, which sidesteps the instance-to-connection problem almost entirely rather than just managing it better.
What to actually do
If you're on RDS, Supabase, or self-hosted Postgres: get a transaction-mode pooler in front of it before you scale past a handful of concurrent users. The singleton pattern is worth adding regardless — it's a few lines of code and it removes the dev-environment version of this bug — but don't mistake it for a fix to the production ceiling.
If you're on Neon, or can move to it, check whether their HTTP driver removes the need for a separate pooler altogether. It's one less piece of infrastructure to run, and for a lot of App Router deployments it's the simpler end state anyway.
Either way, the underlying lesson holds: connection pooling that was designed for long-lived servers doesn't translate to serverless by default. You have to architect for it on purpose.
Accelerate your Fullstack & Database Modernization Roadmap
Need custom architecture auditing, automated OpenAPI contract generation, or zero-downtime microservice migration guidance for your engineering team?
Frequently Asked Questions
Does the singleton pattern for PrismaClient actually fix connection exhaustion?
No, not on its own. It stops you from leaking connections during local hot reloads, but in production every cold serverless instance still opens its own connection. It buys you time, not a ceiling.
Should I use transaction mode or session mode in PgBouncer for a serverless app?
Transaction mode. Session mode holds a database connection for the life of the client session, which defeats the purpose in a serverless environment where "sessions" spin up and die constantly.
Is a connection pooler still necessary if I use a serverless HTTP driver like Neon's?
Often no. HTTP-based drivers query over HTTP/WebSockets instead of holding a raw TCP connection open, so the instance-count-to-connection-count problem mostly disappears. Worth checking your DB provider's own guidance before adding PgBouncer on top.
Subscribe to RenovateAPI
Get weekly architectural guides, API refactoring strategies, and technical SEO updates delivered directly to your inbox.
Discussion (2)
Extremely helpful breakdown of the Strangler Fig pattern! We're currently refactoring a legacy Java monolith at work and the OpenAPI gateway routing tips saved us weeks of experimentation.
The schema JSON-LD and FAQ block structure really helps with indexing. Great technical detail on entity mentions too.
Suggested Related Articles
Stripe Webhooks in NestJS Are Fine Until Production Hits: Fixing Race Conditions and Duplicate Events
How to stop Stripe webhooks from double-charging users, showing stale subscription status, or racing the frontend redirect in a NestJS and PostgreSQL app.
Give Your NestJS AI Agent a Real Memory (Without Blowing Up Your Token Bill)
A two-tier memory pattern for NestJS AI agents — Redis for the last few turns, pgvector for permanent facts — with the code, the tradeoffs, and where it breaks.
Fixing React 19 Hydration Mismatches in Next.js App Router
The three most common causes of hydration mismatch errors in React 19 and Next.js App Router — localStorage reads, timezone-dependent dates, and browser extensions — with working fixes for each.