We deployed a Next.js app to Vercel, wired it up to a Postgres database, and everything worked fine in development. Locally, one server process, one connection pool, predictable behavior. Then we got a modest traffic spike — not even viral, just a newsletter mention — and our database started throwing "too many connections" errors. The app didn't crash dramatically. It just quietly started failing for random users. Took us an embarrassingly long time to figure out why.
The culprit was connection pooling. Or rather, the complete lack of it. This is one of those things that nobody explains when you first go serverless, because in a traditional server setup it Just Works. But serverless is a fundamentally different beast, and your database has no idea what hit it.
What connection pooling actually is (and why you never thought about it before)
Opening a database connection is expensive. Not "costs money" expensive (though it does consume resources), but computationally expensive. There's a TCP handshake, TLS negotiation, authentication, session setup — easily 50-200ms before you've sent a single query. A traditional web server handles this by creating a pool of connections at startup and reusing them across requests. Your Express app boots up, opens 10 connections to Postgres, and every incoming request borrows one, uses it, and returns it to the pool. The database sees a stable, predictable load.
Postgres, for example, has a hard limit on concurrent connections. The default is 100. Each connection consumes about 5-10MB of RAM on the database server. This isn't a Postgres quirk — MySQL, SQL Server, and pretty much every relational database works this way. It's a fundamental constraint of how these systems work.
Why serverless breaks everything
Serverless functions are stateless and ephemeral. Each function invocation might run in a completely fresh environment. There's no persistent process to maintain a connection pool. Every time your Next.js API route or Server Action runs, it potentially opens a new database connection, uses it, and then... leaves it open. Because the function doesn't know when it'll be reused, and connection teardown has overhead too.
So instead of your 10-connection pool serving hundreds of requests per second, you've got potentially hundreds of open connections sitting idle, each eating your database's resources. At low traffic, this is invisible. At moderate traffic, you hit the connection limit and start getting errors. At high traffic, your database falls over completely.
The math is brutal: 10 Vercel functions running concurrently each open 1 connection = 10 connections. Fine. 100 functions = 100 connections. You've just maxed out a default Postgres instance. And Vercel can spin up way more than 100 concurrent functions.
There's also the cold start problem. If your function hasn't run recently and needs to open a fresh connection, that 100-200ms connection overhead gets added to your response time. For an API that should respond in 50ms, this is painful.
The actual solutions (ranked by how much we use them)
There are a few ways to solve this, and they're not mutually exclusive. We'll go through them in order of how often we reach for them.
The first and most practical solution is using a connection pooler that sits between your serverless functions and your database. PgBouncer is the classic choice for Postgres. Services like Supabase, Neon, and Railway give you a PgBouncer endpoint out of the box — you just need to know to use it. Instead of connecting to port 5432 (direct Postgres), you connect to port 6543 (PgBouncer). It maintains a pool of actual database connections and multiplexes your serverless function connections through them.
// Wrong: direct connection in serverless
import { drizzle } from 'drizzle-orm/postgres-js'
import postgres from 'postgres'
// This opens a new connection every time the module loads in a new function instance
const sql = postgres(process.env.DATABASE_URL!) // port 5432
export const db = drizzle(sql)
// Right: use the pooler URL (note the different port)
const sql = postgres(process.env.DATABASE_POOLER_URL!, {
max: 1, // Important: limit connections per function instance
idle_timeout: 20,
connect_timeout: 10,
})
export const db = drizzle(sql)Notice the `max: 1` in the config above. This might feel wrong — shouldn't more connections be faster? Not in serverless. Each function instance should use at most one connection to the pooler. The pooler manages the actual database connections. If you let each function instance open 5 connections to the pooler, and you have 50 concurrent functions, you're back to 250 connections. The pooler handles the multiplexing; you just need one lane into it per function.
Transaction mode vs session mode — this one bites people
PgBouncer has three modes: session, transaction, and statement. In serverless, you almost always want transaction mode. In session mode, a connection is dedicated to your client for the entire session — useless for serverless since sessions are meaningless. In transaction mode, a real database connection is only allocated for the duration of a transaction, then returned to the pool. This is what you want.
But transaction mode has a catch: it doesn't support certain Postgres features that rely on session state. Prepared statements (the default in many ORMs), advisory locks, and `SET` commands don't work properly. This is why you need to disable prepared statements when using PgBouncer in transaction mode.
// With Drizzle + postgres.js via PgBouncer (transaction mode)
import { drizzle } from 'drizzle-orm/postgres-js'
import postgres from 'postgres'
const sql = postgres(process.env.DATABASE_POOLER_URL!, {
max: 1,
// Disable prepared statements for PgBouncer transaction mode
prepare: false,
idle_timeout: 20,
connect_timeout: 10,
})
export const db = drizzle(sql, { schema })
// With Prisma, use the ?pgbouncer=true parameter in your connection string:
// postgresql://user:pass@host:6543/db?pgbouncer=true&connection_limit=1We forgot the `prepare: false` flag once and spent 45 minutes debugging why queries were returning stale data in production but working fine in tests. The prepared statement was cached in one connection, another request got a different connection without the cached plan, and things went sideways in a non-obvious way. Don't skip this.
Neon, PlanetScale, and databases built for this
Some newer databases are designed specifically for serverless and handle the connection problem differently. Neon has a HTTP-based driver that sidesteps TCP connections entirely — each query is an HTTP request, which is naturally stateless and doesn't have the connection limit problem. PlanetScale (when it existed as a service) used a similar approach with their Vitess proxy layer.
// Neon's serverless driver — no persistent connections
import { neon } from '@neondatabase/serverless'
import { drizzle } from 'drizzle-orm/neon-http'
// This uses HTTP under the hood — no connection pool needed
const sql = neon(process.env.DATABASE_URL!)
export const db = drizzle(sql, { schema })
// Each query goes over HTTP — stateless, no connection limits
// The trade-off: slightly higher latency per query vs pooled TCP
// Works great for read-heavy apps, trickier for complex transactionsThe HTTP driver approach is elegant but has trade-offs. Latency per query is typically a bit higher than a pooled TCP connection because HTTP has more overhead than a raw TCP packet. And if you need to run multiple queries in a transaction, you need to use Neon's batch API or their WebSocket-based driver instead. For most CRUD operations, though, the HTTP driver is perfectly fine and removes a whole category of infrastructure problems.
Keeping connections alive across function invocations
Serverless functions aren't always freshly initialized — providers reuse "warm" instances. Next.js on Vercel will often keep your function module loaded between requests. This means if you initialize your database connection at the module level (outside the request handler), it can persist across invocations of the same instance.
// lib/db.ts — module-level singleton
import { drizzle } from 'drizzle-orm/postgres-js'
import postgres from 'postgres'
import * as schema from './schema'
// This runs once per function instance (not per request)
// Vercel will reuse warm instances, so this connection persists
declare global {
// eslint-disable-next-line no-var
var __db: ReturnType<typeof drizzle> | undefined
}
function createDb() {
const sql = postgres(process.env.DATABASE_POOLER_URL!, {
max: 1,
prepare: false,
idle_timeout: 20,
})
return drizzle(sql, { schema })
}
// In development, use global to prevent hot-reload from creating new connections
export const db = globalThis.__db ?? createDb()
if (process.env.NODE_ENV !== 'production') {
globalThis.__db = db
}The `globalThis` trick prevents Next.js's hot module reloading from creating a new connection every time you save a file in development. Without it, you'll accumulate connections in dev until Postgres complains. In production, module-level initialization is fine since there's no hot reloading.
How to tell if you have a connection problem right now
If you're not sure whether this is affecting your app, here's how to check. On Postgres, you can query the connection count directly:
-- Check current connections by state and application
SELECT
application_name,
state,
count(*) as connection_count,
max(now() - state_change) as longest_idle
FROM pg_stat_activity
WHERE datname = current_database()
GROUP BY application_name, state
ORDER BY connection_count DESC;
-- Quick check: how close are you to the limit?
SELECT
current_setting('max_connections')::int as max_connections,
count(*) as current_connections,
current_setting('max_connections')::int - count(*) as remaining
FROM pg_stat_activity;If you see a lot of connections in `idle` state — especially from your application — that's the problem. Those are connections opened by serverless functions that finished their work but haven't been closed. They're just sitting there, consuming a slot, waiting for Postgres to time them out.
- Use your database provider's pooler URL (Supabase port 6543, Neon pooled connection string, etc.) — not the direct connection
- Set `max: 1` (or `connection_limit=1` for Prisma) per function instance
- Disable prepared statements when using PgBouncer in transaction mode
- Initialize the DB client at module level, not inside the request handler
- Use the globalThis singleton pattern in development to avoid hot-reload connection leaks
- Consider Neon's HTTP driver if you want to sidestep the problem entirely
The worst part about connection exhaustion is that it doesn't fail fast. Your app starts throwing random errors, some requests work, some don't, and there's no obvious error message pointing at connections. Profile first, then fix.
One more thing worth mentioning: if you're using Prisma, their Accelerate product is essentially a managed connection pooler with edge caching on top. It's not free at scale, but it handles all of this for you transparently. Drizzle doesn't have a managed equivalent, so you're responsible for your pooler configuration — which is why we've been explicit about the setup above.
When we built the database layer for our Next.js templates at peal.dev, we defaulted to the pooler URL setup with Drizzle and Neon because it works correctly out of the box without any extra infrastructure. The Neon HTTP driver for simple queries, WebSocket driver for transactions. Newcomers don't have to think about any of this — it's just wired up correctly from the start.
Connection pooling is one of those things that feels like an advanced topic until you run into the problem, and then it feels embarrassingly obvious in hindsight. The fix is usually three lines of config. The hard part is knowing you need it before your users start seeing errors.
