RA
RenovateAPIEngineering Hub
Fullstack & Database

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.

PPLACEHOLDER_AUTHOR6 min read
Fullstack & Database6 min read

Why Your Next.js App Keeps Hitting "Too Many Connections" in Production

RENOVATEAPI ARCHITECTURAL SPEC
CANONICAL GUIDE

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.

RenovateAPI Engineering Suite

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.

Weekly Engineering Dispatch

Subscribe to RenovateAPI

Get weekly architectural guides, API refactoring strategies, and technical SEO updates delivered directly to your inbox.

Discussion (2)

A
Alex Rivera
2 hours ago

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.

S
Sophia Chen
1 day ago

The schema JSON-LD and FAQ block structure really helps with indexing. Great technical detail on entity mentions too.

Suggested Related Articles