The serverless connection exhaustion problem with real math (100 functions × pool_size > max_connections), PgBouncer transaction vs session mode, Neon serverless driver, Prisma Accelerate, Supabase pooler, the pgbouncer=true flag, monitoring pg_stat_activity, and a decision matrix by infrastructure type.
P-15 — Connection Pooling: The Deep Dive
Who this is for: Engineers who have read P-4, understand that "use a singleton and a pooler" is the answer, and now need to understand why it works that way, how to configure it correctly, and what happens when you get it wrong at 3am with 240 active connections on a database configured for 100. This module is the unabridged treatment. No shortcuts.
The Serverless Connection Exhaustion Problem
Let me tell you about a ProductHunt launch.
The app had been running fine in production for three weeks. Low traffic, no issues. The team submitted to ProductHunt, went to sleep (mistake), and woke up to an inbox full of Sentry alerts. The app was returning 500s. The database wasn't down — the RDS console showed the instance was healthy, CPU at 12%, memory fine. But the connection count graph looked like a vertical line: it had gone from 8 to 240 in 90 seconds.
The database was configured for 100 connections. There were 240 active connections. Every one of them was idle — just sitting there, held open by Prisma's connection pool across warm Lambda instances. PostgreSQL had refused new connections at connection 98, reserving the last two slots for the superuser. The app showed 500s. The error in the logs:
FATAL: remaining connection slots are reserved for non-replication superuser connections
Or depending on how many you've exceeded:
ERROR: too many connections
Here is the math that causes this. It is brutally simple, which is why it surprises so many teams:
- PostgreSQL default
max_connections: 100 (plus 3 reserved for superuser, so 97 available for applications) - Prisma default connection pool size: 5 per
PrismaClientinstance (actuallynum_cpus * 2 + 1, but typically 5 on Lambda) - Serverless: each function invocation runs in its own process —
globalThisis not shared across invocations - At 20 concurrent Lambda invocations: 20 × 5 = 100 connections — you've hit the limit exactly
- At 25 concurrent invocations: 25 × 5 = 125 connections — PostgreSQL starts refusing
In development you never see this. You have one process, one PrismaClient, one pool. Traffic goes up, queries queue inside the pool, the pool handles it. The pool is doing exactly what it's designed to do. Everything looks fine.
In production on Vercel or Lambda, traffic goes up and Lambda scales horizontally. Each new Lambda instance is a fresh process. Each process creates a new PrismaClient. Each PrismaClient opens up to 5 connections to PostgreSQL. The pool is doing exactly what it's designed to do — on a per-instance basis. The problem is that you now have 50 instances each with 5 connections, and PostgreSQL's total connection limit is shared across all of them.
The ProductHunt story above involved going from near-zero to 2,000 requests per minute. Lambda scaled to handle it. Within 90 seconds there were 240 active connections on an instance configured for 100. The connections were idle because warm Lambda instances hold the pool open. The app showed 500s. Nobody had done anything wrong at the code level — they'd done everything "right" according to the standard Prisma singleton documentation. The singleton pattern prevents multiple pools within a single process. It does nothing to prevent multiple pools across processes.
This is the core mismatch between how databases work and how serverless works. Understanding it deeply is the prerequisite for understanding everything else in this module.
Why Long-Lived Pools Don't Save You in Serverless
Connection pooling was designed for traditional server architectures. The model looks like this:
- One Node.js process running continuously
- One
PrismaClientwith a pool of, say, 10 connections - 1,000 requests per second come in
- They all go through the same process, the same pool
- The pool queues requests, multiplexes them across the 10 connections
- PostgreSQL sees 10 connections, always
This is exactly right. Ten connections handling 1,000 RPS works because each database query is fast — typically 1–50ms. Ten connections can each serve 20–1,000 queries per second. The math works.
Serverless breaks this model in one specific way: the pool is not shared across invocations. Lambda, Cloud Run, and Vercel Functions are all process-per-invocation environments (with warm reuse, but warm reuse is not guaranteed and not bounded). During a traffic spike:
- Lambda can scale from 0 to 500 concurrent instances in seconds
- Each instance creates its own pool
- You cannot predict how many instances will be running at any given moment
- Therefore you cannot predict how many total connections you'll hold against PostgreSQL
There is no configuration you can set on PrismaClient that fixes this, because the problem is architectural. You can set connection_limit=1 and then you're at 500 connections for 500 Lambda instances instead of 2,500 — still five times your database's limit.
Warm instance reuse helps. If the same Lambda instance handles multiple requests sequentially, it reuses the pool and doesn't open new connections. But:
- Warm reuse is sequential, not parallel. Two concurrent requests to the same Lambda mean two different instances.
- Lambda's maximum number of concurrent instances scales with your concurrency, not with your configuration.
- Under a traffic spike — the exact scenario where connection exhaustion is most dangerous — Lambda is spinning up new instances as fast as it can.
The only correct solutions sit outside the application process entirely: a connection proxy that PostgreSQL sees as a small fixed set of connections regardless of how many application processes connect to it, or a stateless query transport that doesn't use persistent TCP connections at all.
Solution 1 — PgBouncer: The Connection Proxy
PgBouncer is a single process that sits between your application and PostgreSQL. Its job is to maintain a small, fixed pool of actual PostgreSQL connections and multiplex many application connection requests across that pool.
The architecture looks like this:
Lambda instance 1 (pool of 5) ─┐
Lambda instance 2 (pool of 5) ─┤
Lambda instance 3 (pool of 5) ─┼──→ PgBouncer (pool of 20) ──→ PostgreSQL (sees 20)
Lambda instance 4 (pool of 5) ─┤
Lambda instance 5 (pool of 5) ─┘
From PostgreSQL's perspective, there are 20 connections. Always. Regardless of whether there are 5 Lambda instances or 500. PgBouncer queues application connection requests that arrive when all 20 server-side connections are busy and dispatches them as connections become available.
This is fundamentally different from application-level pooling. PgBouncer is a shared infrastructure component, not a per-process library. Its pool count is your real connection cost.
Transaction Pooling vs Session Pooling — This Distinction Matters More Than You Think
PgBouncer operates in one of three modes. Getting this wrong is the most common PgBouncer misconfiguration.
Session mode (pool_mode = session): PgBouncer assigns a server-side PostgreSQL connection to a client connection for the entire duration of that client session. The connection is only returned to the pool when the client disconnects. This supports all PostgreSQL features — prepared statements, SET session variables, advisory locks, LISTEN/NOTIFY, cursors — because the client has exclusive use of a server connection throughout its session. The problem: there is almost no multiplexing benefit. If your application holds connections open between requests (which connection pools do, by design), PgBouncer in session mode provides no benefit over direct connections.
Transaction mode (pool_mode = transaction): PgBouncer assigns a server-side connection to a client only for the duration of a transaction. When the transaction commits or rolls back, the server connection is returned to the pool and can immediately be used by a different client. This is maximum multiplexing — a pool of 20 server connections can serve hundreds of concurrent application connections because each one only holds the server connection for the ~5ms a transaction takes. This is the correct mode for serverless. This is the mode that makes PgBouncer worth running.
The catch: transaction mode has feature restrictions.
- No prepared statements across transaction boundaries. Prepared statements are server-side state. In transaction mode, you can't guarantee the same server connection services both the prepare and the execute. For Prisma: add
?pgbouncer=trueto your connection string. This tells Prisma to use unnamed prepared statements (effectively, no persistent prepared statement cache), which is compatible with transaction mode. Without this flag, Prisma's prepared statements will cause subtle errors or connection failures. - No
SETsession variables.SET search_path = myschemaat the start of a connection won't persist across transactions because the server connection may change. - No advisory locks across transactions. Advisory locks are session-scoped in PostgreSQL. In transaction mode, a lock acquired in one transaction may be on a server connection that gets reassigned before your next transaction runs.
- No
LISTEN/NOTIFY. These are session-scoped. Use a dedicated direct connection for pub/sub. - No cursors across transaction boundaries. Cursors are server-side state.
For the vast majority of Next.js applications — REST/GraphQL APIs, Server Actions, Route Handlers — none of these restrictions matter. You're doing CRUD inside discrete transactions, and transaction mode is safe. If you need LISTEN/NOTIFY or long-running cursors, use a separate direct connection for that specific use case.
Statement mode (pool_mode = statement) returns the server connection after every single SQL statement. This breaks any multi-statement transaction. Do not use this for application workloads.
PgBouncer Configuration
A minimal pgbouncer.ini for a production serverless workload:
ini[databases] mydb = host=postgres-host.internal port=5432 dbname=mydb [pgbouncer] listen_addr = 0.0.0.0 listen_port = 6432 auth_type = md5 auth_file = /etc/pgbouncer/userlist.txt ; The critical settings pool_mode = transaction max_client_conn = 1000 ; max application-side connections PgBouncer accepts default_pool_size = 20 ; actual server-side connections to PostgreSQL ; Timeouts — tune these or you'll get mysterious hangs server_idle_timeout = 600 ; close server connections idle for 10 minutes client_idle_timeout = 0 ; never close idle client connections (Lambda holds them) query_timeout = 30 ; kill queries running longer than 30 seconds query_wait_timeout = 30 ; fail client if no server connection available in 30s ; Logging log_connections = 0 ; noisy at scale, disable in production log_disconnections = 0 stats_period = 60 ; emit stats every 60 seconds
max_client_conn is what your application connects to. default_pool_size is what PostgreSQL sees. The ratio between these two numbers is the multiplexing factor PgBouncer provides.
Prisma Connection String with PgBouncer
Two connection strings are required: one through PgBouncer for application queries, one direct to PostgreSQL for migrations. Prisma's migration engine uses advisory locks, which require session mode. Running migrations through PgBouncer in transaction mode will cause the migration to hang indefinitely.
env# .env DATABASE_URL="postgresql://user:pass@pgbouncer-host:6432/mydb?pgbouncer=true" DIRECT_URL="postgresql://user:pass@postgres-host:5432/mydb"
prisma# prisma/schema.prisma datasource db { provider = "postgresql" url = env("DATABASE_URL") # ← PgBouncer for app queries directUrl = env("DIRECT_URL") # ← PostgreSQL directly for migrations }
The ?pgbouncer=true query parameter is the Prisma-specific signal to disable persistent prepared statements. Without it, Prisma sends PREPARE statements that reference server-side prepared statement names — names that won't exist on the next server connection PgBouncer assigns.
For deployment, run migrations using prisma migrate deploy which uses directUrl automatically. On Vercel, run migrations as a build step or as a separate migration job — never via the pooled URL.
Where to Run PgBouncer
For AWS workloads, RDS Proxy is AWS's managed PgBouncer equivalent. It handles the operational overhead (HA, failover, IAM auth) at the cost of added latency (~1–2ms) and per-connection pricing. For teams who don't want to operate PgBouncer themselves, RDS Proxy is reasonable.
For self-managed infrastructure: run PgBouncer as a sidecar container in the same Kubernetes pod as your application, or as a shared deployment in your VPC. Running it as a sidecar means it's local (sub-millisecond latency) but you have one PgBouncer per pod rather than one shared PgBouncer. The math changes: 10 pods × 20 PgBouncer server connections = 200 PostgreSQL connections. Still bounded, still predictable — just multiply default_pool_size by pod count.
Solution 2 — Neon Serverless Driver
PgBouncer is infrastructure you have to operate. For teams using Neon (serverless PostgreSQL) or who want to avoid the operational overhead, the Neon serverless driver takes a different approach: eliminate persistent TCP connections entirely.
The Architecture
The @neondatabase/serverless package provides two transports:
HTTP mode: Each query is a single HTTP POST request to Neon's API endpoint. No TCP connection is established. The query goes out as an HTTP request, the result comes back as an HTTP response, and that's it. No connection state, no pool, nothing to manage. Neon handles connection management on its end.
WebSocket mode: A WebSocket is established, used for one or more queries, and torn down. The connection lifecycle is ~1ms. From PostgreSQL's perspective, these appear as short-lived connections that don't linger.
Neither mode maintains a persistent pool on the application side. You cannot exhaust PostgreSQL's connection limit because you don't hold connections between queries.
This is architecturally different from PgBouncer. PgBouncer is a proxy that manages connections on your behalf. The Neon driver doesn't use persistent connections at all.
Using the Neon Driver with Drizzle
For Drizzle ORM, which integrates cleanly with the Neon HTTP driver:
typescriptimport { neon } from '@neondatabase/serverless'; import { drizzle } from 'drizzle-orm/neon-http'; import * as schema from './schema'; const sql = neon(process.env.DATABASE_URL!); const db = drizzle(sql, { schema }); // Use exactly like any Drizzle instance const users = await db.select().from(schema.users).where(eq(schema.users.active, true));
Each call to db.select() (or any query method) goes out as an HTTP request. No pool, no persistent state.
Using the Neon Driver with Prisma
Prisma's driver adapter system lets you swap the underlying transport while keeping the Prisma ORM API:
typescriptimport { neon } from '@neondatabase/serverless'; import { PrismaNeon } from '@prisma/adapter-neon'; import { PrismaClient } from '@prisma/client'; // For serverless: create per-invocation or use the global singleton const sql = neon(process.env.DATABASE_URL!); const adapter = new PrismaNeon(sql); export const prisma = new PrismaClient({ adapter });
With the Neon adapter, Prisma uses HTTP under the hood instead of TCP. The PrismaClient still needs to be instantiated (and the global singleton pattern still prevents multiple instances during HMR), but its underlying connections are stateless HTTP requests rather than pooled TCP sockets.
The Trade-Off: Latency vs Connection Safety
HTTP-based queries have measurable overhead versus a persistent TCP connection. In round numbers:
- Persistent TCP connection (direct or via PgBouncer): query latency ≈ network RTT to database + query execution time
- HTTP query (Neon driver): query latency ≈ network RTT to Neon's edge + query execution time + HTTP overhead (~10–30ms additional)
For applications making 1–3 database calls per request, this overhead is acceptable — it's on the order of the variance you'd see from query plan changes. For applications making 10+ database queries per request path (usually a sign of an N+1 problem worth fixing anyway), the HTTP overhead becomes noticeable.
The Neon driver also works in Edge Runtime — Vercel's Middleware environment and Edge Functions. Prisma's standard Node.js driver does not. If you need to query your database from Middleware (generally a code smell, but occasionally necessary), the Neon HTTP driver is one of very few options.
Solution 3 — Prisma Accelerate
Prisma Accelerate is Prisma's managed connection pooler plus edge caching service. It occupies the same architectural position as PgBouncer — it's a proxy between your application and PostgreSQL — but it's fully managed and adds query-level caching as a first-class feature.
How It Works
Your application connects to prisma://accelerate.prisma.io/ rather than directly to your PostgreSQL host. Accelerate handles:
- Connection pooling: Accelerate maintains a pool of real PostgreSQL connections. Your application connects to Accelerate, which multiplexes those connections to your database. Identical behavior to PgBouncer from the application's perspective.
- Query caching: Optionally, you can specify a TTL per query. Accelerate caches the result and serves it without touching the database on subsequent identical queries within the TTL window.
- Global edge network: Accelerate runs on Prisma's edge network, meaning the proxy hop is geographically close to your users (if you're using edge functions) or to your serverless region.
Setup
bashnpm install @prisma/extension-accelerate
typescriptimport { PrismaClient } from '@prisma/client'; import { withAccelerate } from '@prisma/extension-accelerate'; const prisma = new PrismaClient().$extends(withAccelerate());
The connection string format changes:
envDATABASE_URL="prisma://accelerate.prisma.io/?api_key=your_api_key_here"
You still need a DIRECT_URL pointing at your actual PostgreSQL instance for migrations:
envDIRECT_URL="postgresql://user:pass@postgres-host:5432/mydb"
Query-Level Caching
This is the feature that distinguishes Accelerate from a plain connection pooler. Per-query cache control with TTL and stale-while-revalidate semantics:
typescript// Cache this query for 60 seconds, serve stale for up to 600 seconds while revalidating const users = await prisma.user.findMany({ cacheStrategy: { ttl: 60, swr: 600 } }); // No caching — always hit the database const freshUser = await prisma.user.findUnique({ where: { id: userId }, // No cacheStrategy = no caching }); // Cache indefinitely, rely on explicit invalidation const config = await prisma.appConfig.findFirst({ cacheStrategy: { ttl: 3600, tags: ['app-config'] } });
The swr (stale-while-revalidate) parameter mirrors the HTTP cache-control pattern: serve the cached response immediately, kick off a background revalidation if the TTL has expired but the SWR window hasn't. Users get fast responses; the cache stays fresh eventually.
Cache invalidation by tag:
typescriptimport { Prisma } from '@prisma/client/edge'; // In a mutation action, after updating config: await prisma.$accelerate.invalidate({ tags: ['app-config'] });
The Trade-Offs
Accelerate's connection pooling works. The caching layer is genuinely useful for read-heavy endpoints that can tolerate eventual consistency. The trade-offs are real:
Vendor dependency: Your database connectivity now depends on Prisma's infrastructure uptime. If Accelerate has an outage, your application loses database access even if your PostgreSQL instance is healthy. Evaluate whether this risk profile is acceptable for your workload.
Added latency hop: Every query goes through Accelerate's infrastructure before reaching your database. Prisma claims this is typically sub-5ms for co-located infrastructure, but it's a network hop you don't have with direct PgBouncer.
Pricing: Accelerate is a paid service beyond the free tier. Model the cost against your query volume before committing.
For teams already invested in the Prisma ecosystem and who want connection pooling and edge caching without operational overhead, Accelerate is a pragmatic choice. For teams who need full control, want to minimize external dependencies, or are running on self-managed infrastructure, PgBouncer is the more durable solution.
Solution 4 — Supabase Connection Pooler (Supavisor)
If you're using Supabase-hosted PostgreSQL, you have a managed connection pooler included without additional infrastructure: Supavisor.
Supabase replaced PgBouncer with Supavisor (a native Elixir pooler) in 2024. The behavior is similar to PgBouncer in transaction mode with some Supabase-specific enhancements (multi-tenant pooling, PostgreSQL parameter pass-through for row-level security).
Connection String Difference
Supabase provides two ports for every project:
- Port 5432 — direct PostgreSQL connection, no pooling. Use this only for migrations, not for application queries in serverless.
- Port 6543 — Supavisor pooled connection, transaction mode. Use this for all application queries in serverless and high-concurrency deployments.
env# Application queries — use the pooler DATABASE_URL="postgresql://postgres.projectref:password@aws-0-region.pooler.supabase.com:6543/postgres" # Migrations — use the direct connection DIRECT_URL="postgresql://postgres.projectref:password@aws-0-region.pooler.supabase.com:5432/postgres"
The hostname and credentials are the same — only the port differs. This is the only configuration change required; everything else (Prisma schema, singleton pattern, query code) stays identical.
One Supabase-specific note: if you're using Row Level Security (RLS) with SET role or SET app.current_user, be aware that Supavisor in transaction mode has the same session-variable restrictions as PgBouncer transaction mode. Supabase has workarounds using connection string parameters for JWT passing — check the current Supabase docs for the specific RLS pattern if you need it, as this evolves faster than I want to hard-code here.
The Global Singleton Pattern for Non-Serverless
Everything above covers serverless. For always-on deployments — Kubernetes, Fly.io, Railway, a bare VM — the calculus is different.
In a traditional deployment, you have a fixed, known number of processes. Each process should have exactly one PrismaClient with a pool sized to its share of max_connections. The problem to avoid is not concurrency across hundreds of Lambda instances — it's the Next.js development server creating a new PrismaClient instance on every file change via hot module replacement.
Why HMR Kills Development Connections
Next.js's development server uses HMR to reload changed modules without restarting the entire server. When lib/prisma.ts (or any module it imports) changes, Node.js re-evaluates the module. Without special handling, this creates a new PrismaClient instance each time — complete with a new connection pool — while the old instance sits unreachable, its connections held open until they time out. Do this 50 times in a dev session and you've got 50 × 5 = 250 connections sitting idle.
The Correct Singleton Pattern
typescript// lib/prisma.ts import { PrismaClient } from '@prisma/client'; const globalForPrisma = globalThis as unknown as { prisma: PrismaClient }; 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;
The mechanism: globalThis is not subject to HMR. Module-level variables are — they get reset on every hot reload. But globalThis persists for the lifetime of the Node.js process. The first time this module is evaluated, globalForPrisma.prisma is undefined, a new PrismaClient is created, and it's stored on globalThis. Every subsequent evaluation (every hot reload) finds the existing instance on globalThis and returns it.
The if (process.env.NODE_ENV !== 'production') guard is deliberate. In production, the module is evaluated once at startup and never again — storing on globalThis is unnecessary. The guard prevents the pattern from adding any overhead or confusion in production environments.
This is the only correct pattern for non-serverless Next.js. Variations that use module-level variables without the globalThis trick will leak connections in development. Patterns that create a new PrismaClient on every import (including the new PrismaClient() inside a Server Component) will exhaust connections in any environment.
Add import 'server-only' to lib/prisma.ts to make the build fail loudly if this module is ever imported in a Client Component:
typescript// lib/prisma.ts import 'server-only'; import { PrismaClient } from '@prisma/client'; const globalForPrisma = globalThis as unknown as { prisma: PrismaClient }; 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;
Explicitly Disconnecting on Process Shutdown
In non-serverless environments, add a process shutdown handler to ensure Prisma closes its connections cleanly. Without this, a SIGTERM during a Kubernetes rolling deploy will leave connections dangling until PostgreSQL's idle timeout clears them:
typescript// lib/prisma.ts (non-serverless, complete version) import 'server-only'; import { PrismaClient } from '@prisma/client'; const globalForPrisma = globalThis as unknown as { prisma: PrismaClient }; 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; // Graceful shutdown — important in containerized environments if (process.env.NODE_ENV === 'production') { process.on('beforeExit', async () => { await prisma.$disconnect(); }); }
Monitoring: How to Know You're Exhausting Connections
The time to know about connection exhaustion is not when users see 500s. It's an hour before that, when connection count starts trending toward the limit. Here are the queries to run:
sql-- Total active connections right now SELECT count(*) FROM pg_stat_activity; -- Connections by state -- States: 'active' (query running), 'idle' (waiting for client), -- 'idle in transaction' (inside a transaction, not running a query), -- 'idle in transaction (aborted)' (transaction failed, not rolled back) SELECT state, count(*) FROM pg_stat_activity GROUP BY state ORDER BY count DESC; -- Connections grouped by client application -- Tells you which application or service is holding connections SELECT application_name, count(*) FROM pg_stat_activity GROUP BY application_name ORDER BY count DESC; -- Long-running queries (>30 seconds) — often the cause of 'idle in transaction' SELECT pid, now() - pg_stat_activity.query_start AS duration, query, state FROM pg_stat_activity WHERE (now() - pg_stat_activity.query_start) > interval '30 seconds' ORDER BY duration DESC; -- Connections that are actively waiting for something -- High numbers here indicate lock contention or an overwhelmed server SELECT count(*) FROM pg_stat_activity WHERE wait_event_type IS NOT NULL; -- Specifically: client-side waits (PgBouncer clients waiting for a server connection) SELECT count(*) FROM pg_stat_activity WHERE wait_event_type = 'Client' AND wait_event = 'ClientRead';
The idle in transaction state is worth calling out specifically. A connection in this state is inside a transaction that hasn't committed or rolled back — usually because the application code started a transaction, hit an error, and didn't properly ROLLBACK. These connections hold locks. They block other transactions. They are often the root cause of cascading slowdowns that look like a connection exhaustion problem but are actually a lock contention problem.
If you see a high count of idle in transaction connections, look for:
- Unhandled exceptions inside Prisma
$transaction()blocks - Long-running background processes that open transactions and take time between statements
- Application code that manually starts transactions (
BEGIN) but doesn't always commit or roll back
What to Alert On
Minimum viable alerting against pg_stat_activity:
- Warning:
count(*) > max_connections * 0.7— at 70% of your connection limit, something is trending wrong - Critical:
count(*) > max_connections * 0.85— at 85%, start paging; you're 90 seconds from user-visible errors - Emergency:
count(*) WHERE state = 'idle in transaction' > 10— investigate immediately; these connections are holding locks - Latency signal:
count(*) WHERE wait_event_type IS NOT NULL > 20— connections are waiting; throughput is being throttled
A Datadog monitor on the RDS DatabaseConnections metric is simpler than polling pg_stat_activity and sufficient for most teams. Add an alert threshold at 70% and 90% of max_connections. On Supabase, the dashboard shows connection count by default.
For teams running self-managed PostgreSQL, pg_stat_activity polling via a simple cron job or Prometheus exporter (postgres_exporter is the standard tool) gives you the visibility you need before it becomes a production incident.
The Decision Matrix
Mapping your infrastructure to the right pooling strategy. Do not use direct connections to PostgreSQL in serverless. Do not add PgBouncer overhead to always-on servers that don't need it.
| Infrastructure | Strategy | Notes |
|---|---|---|
| Vercel / Netlify Serverless | Neon HTTP driver or Prisma Accelerate | Never use a direct connection. The singleton pattern alone is insufficient. |
| AWS Lambda | PgBouncer in transaction mode | Self-managed or RDS Proxy. Add ?pgbouncer=true to connection string for Prisma. |
| Always-on K8s / Railway / Fly.io | Global singleton PrismaClient | Tune connection_limit to floor(max_connections / num_pods) - buffer. |
| Supabase hosted | Supavisor on port 6543 | Do not use direct port 5432 in production. |
| Edge Runtime (Middleware) | Neon HTTP driver or Drizzle + Neon | Prisma's Node.js driver does not run in Edge Runtime. |
| Docker Compose (local dev) | Direct connection | Single process, no concurrency problem. Use DIRECT_URL setup for future-proofing. |
The "never" in the Vercel row is worth reinforcing: Vercel Functions are serverless, full stop. The global singleton pattern does not help you there. Every engineer who has shipped a Vercel application with a direct PostgreSQL connection and a Prisma singleton is running on borrowed time. They haven't hit the connection limit because they haven't hit enough concurrent traffic. The fix is not expensive or complicated — it's one of the solutions above. Do it before you're on call during a traffic spike.
The pool_size Formula
For always-on deployments where you're tuning the application-level pool rather than relying on a proxy, here is the actual math.
The constraint: Total connections across all running instances must not exceed max_connections - superuser_reserved. PostgreSQL's default is 100 total, 3 reserved for superuser, so 97 available for applications. In practice, add a safety buffer — don't run at 97.
The formula:
pool_size_per_instance = floor(max_connections / num_instances) - safety_buffer
Example: 100 max_connections, 10 Kubernetes pods, safety buffer of 5 (for monitoring tools, migration runners, manual psql connections):
pool_size = floor(100 / 10) - 5 = 10 - 5 = 5
Each pod runs a PrismaClient with connection_limit=5. Ten pods × 5 connections = 50 total. You have 47 headroom before hitting the limit (the other 50 are your buffer and superuser slots).
Setting connection_limit in Prisma:
Via the connection string:
envDATABASE_URL="postgresql://user:pass@host:5432/mydb?connection_limit=5"
Via PrismaClient constructor (for dynamic configuration):
typescriptconst connectionLimit = Math.max( 1, Math.floor( (parseInt(process.env.PG_MAX_CONNECTIONS ?? '100') / parseInt(process.env.NUM_INSTANCES ?? '1')) - 5 ) ); export const prisma = new PrismaClient({ datasources: { db: { url: `${process.env.DATABASE_URL}?connection_limit=${connectionLimit}`, }, }, log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'], });
Pass PG_MAX_CONNECTIONS and NUM_INSTANCES as environment variables per deployment environment. Your staging environment with 2 pods and a shared dev database uses different values than production with 10 pods and a dedicated RDS instance.
Adjusting for deployment scale changes: If you scale from 10 to 20 pods, halve the connection_limit per pod before the scale-out happens (or set it based on the maximum expected pod count, not current). If you increase PostgreSQL's max_connections (which requires a database restart on most managed services), recalculate and update the connection strings.
The pool_timeout consideration: Prisma's default pool timeout is 10 seconds — if a query waits more than 10 seconds for a connection from the pool to become available, Prisma throws PrismaClientKnownRequestError with code P2024 (pool timeout). Under high concurrency with a small pool, you'll see this before you see PostgreSQL-level connection rejection. Increase pool_timeout in the connection string if you're comfortable with queries waiting longer:
envDATABASE_URL="postgresql://user:pass@host:5432/mydb?connection_limit=5&pool_timeout=30"
Longer timeout buys more buffering at the cost of slower failure detection. A pool timeout error returned to the client is better than a 500 — at minimum it's a recoverable error with a meaningful message rather than a raw connection failure.
Bringing It Together: The Configuration Checklist
Before shipping a Next.js application with a database:
For Vercel/serverless:
-
DATABASE_URLpoints to a connection pooler (PgBouncer, Neon, Accelerate, Supavisor), not directly to PostgreSQL -
DIRECT_URLexists and points directly to PostgreSQL for migrations -
prisma/schema.prismahas bothurl = env("DATABASE_URL")anddirectUrl = env("DIRECT_URL") - If using PgBouncer:
?pgbouncer=trueis inDATABASE_URL - Migration command (
prisma migrate deploy) runs againstDIRECT_URL - Alert configured when connection count exceeds 70% of
max_connections
For always-on (K8s/Railway/Fly.io):
-
lib/prisma.tsuses theglobalThissingleton pattern withimport 'server-only' -
connection_limitper pod is set tofloor(max_connections / num_pods) - buffer - Graceful shutdown handler calls
prisma.$disconnect()onbeforeExit - Alert configured when connection count exceeds 70% of
max_connections
For both:
-
new PrismaClient()is never called outsidelib/prisma.ts -
lib/prisma.tsis never imported in a Client Component -
pg_stat_activity(or the managed service equivalent) is in your monitoring dashboard
The ProductHunt story at the top of this module didn't have to happen. The fix is one connection string change and a pooler config. The teams who get burned are the ones who read "use a singleton" in a tutorial, ship it, and don't revisit until the 3am alert fires.
Don't be that team.
Where We Go From Here
P-15 is the final module in the Practitioner phase's database track. The Architect phase begins with A-1 — React Server Components at the protocol level. Understanding the React Flight wire format explains why Server Components cannot import PrismaClient directly, why serialization boundaries exist, and how Next.js decides what runs where. The connection pooling rules you've internalized here make full sense once you understand the process topology RSC imposes.