// LOADING
// LOADING
// LOADING_ARTICLE
A traditional Node.js server initializes a database connection pool once at startup and reuses those connections for every incoming request. Serverless functions have no persistent process — each cold start can open a fresh connection, and under load you can have hundreds of function instances each holding their own pool. The math gets ugly fast.
In a long-running Express server with maxPoolSize: 10, you hold at most 10 connections regardless of traffic volume. In a serverless environment:
maxPoolSize connections for its lifetime.With 100 warm Lambda instances and maxPoolSize: 10, you are holding 1,000 connections. A typical MongoDB Atlas M10 cluster supports around 1,500 total connections. A free-tier cluster allows far fewer. This is one of the most common causes of MongoServerSelectionError: connection pool paused in production Next.js apps.
Serverless containers are sometimes reused between invocations (warm starts). The module scope — code outside the handler — persists across warm invocations. Cache the connection there:
ts// lib/db.ts — Next.js / Vercel Functions import mongoose from 'mongoose' declare global { // eslint-disable-next-line no-var var _mongooseConn: { conn: typeof mongoose | null promise: Promise<typeof mongoose> | null } } const cache = global._mongooseConn ?? { conn: null, promise: null } global._mongooseConn = cache export async function connectDb(): Promise<typeof mongoose> { if (cache.conn) return cache.conn if (!cache.promise) { cache.promise = mongoose.connect(process.env.MONGODB_URI!, { maxPoolSize: 5, minPoolSize: 0, maxIdleTimeMS: 10_000, serverSelectionTimeoutMS: 5_000, }) } cache.conn = await cache.promise return cache.conn }
Using global._mongooseConn instead of a module-level variable is important in Next.js development mode. Hot Module Replacement discards the module scope while keeping the same Node.js process, so a module-level cache would be lost on every save. The global object survives HMR.
This pattern integrates cleanly with a well-structured Next.js project where the database connection is centralized in a single lib/db.ts module and imported wherever needed.
In serverless, lower maxPoolSize is almost always correct:
tsmongoose.connect(uri, { maxPoolSize: 1, // aggressive but common for low-traffic routes minPoolSize: 0, // do not keep idle connections alive maxIdleTimeMS: 10_000, // close a connection idle for 10 seconds socketTimeoutMS: 20_000, })
maxPoolSize: 1 means queries within a single invocation run sequentially rather than in parallel. For most API route handlers this is fine — requests are handled one at a time anyway. Use a slightly higher pool (3–5) only if a single invocation genuinely runs multiple independent queries concurrently with Promise.all().
The formula to not exceed your database limit:
maxPoolSize × peak_concurrent_instances < database_connection_limit
For PostgreSQL, putting PgBouncer (or a managed equivalent like Supabase's pooler or Neon's connection pooler) between your functions and Postgres is the standard solution. PgBouncer multiplexes many short-lived application connections onto a small, long-lived set of real database connections in transaction-pooling mode.
For MongoDB, MongoDB Atlas includes a load balancer that pools connections. Using the mongodb+srv connection string with Atlas automatically routes through this layer. Some teams also route through a dedicated proxy like mongos on sharded clusters.
External poolers move the connection management concern out of your application code entirely, at the cost of an additional network hop.
Some modern database services expose an HTTP or WebSocket API rather than a persistent TCP connection: Neon Serverless (Postgres over HTTP/WebSockets), PlanetScale (MySQL over HTTP), and Turso (SQLite-compatible over HTTP). These are designed for serverless from the ground up and eliminate the connection limit problem at the cost of slightly higher per-query latency.
ts// Neon serverless driver — no connection pool needed import { neon } from '@neondatabase/serverless' const sql = neon(process.env.DATABASE_URL!) const rows = await sql`SELECT * FROM posts WHERE published = true`
Each query is an HTTP request. There is no pool to configure and no connection limit to manage.
Multi-document transactions hold a session tied to a specific connection for their duration. If a serverless function is frozen or terminated mid-transaction, the session is abandoned and MongoDB times it out server-side (default: 60 seconds). Keep transactions as short as possible and always use session.withTransaction() which handles the retry logic for WriteConflict errors.
For a full discussion of when transactions are appropriate see database transactions: when you actually need them.
Connection exhaustion is a silent problem that shows up as intermittent timeouts rather than obvious errors. Add these to your observability setup:
db.serverStatus().connections.current in MongoDB or watch pg_stat_activity row count in Postgres.For N+1 query patterns that already stress the database, connection exhaustion makes things dramatically worse — fix both together.
global._mongooseConn in Next.js.maxPoolSize to 1–5 per serverless instance.minPoolSize: 0 and maxIdleTimeMS to release idle connections.You can see these patterns applied in production-grade API routes in the projects section. Connection pooling is not glamorous, but getting it wrong is one of the fastest ways to take down a serverless application under real traffic.