The Problem (The “Why”)
Serverless functions create a new execution environment for each invocation, and naive MongoDB connections open a new connection pool every time—exhausting Atlas’s connection limits (100 for M0, 500 for M2) within seconds under load. Each AWS Lambda, Vercel function, or Cloudflare Worker tries to maintain its own pool, leading to “Too many connections” errors, cold start penalties from repeated handshakes, and wasted resources. The core challenge: you need persistent connections for performance but serverless environments are ephemeral. Connection pooling libraries designed for long-running servers fail in serverless contexts. Developers either disable pooling entirely (terrible performance) or implement broken caching that leaks connections across invocations. Production serverless apps need singleton connection patterns, proper connection lifecycle management, configurable pool sizes for different tiers, automatic cleanup on function termination, and health checks that detect stale connections. This implementation solves all of it with battle-tested patterns for Next.js, AWS Lambda, and Vercel.
Tech Stack & Prerequisites
- Node.js v20+ and npm/pnpm
- MongoDB Atlas account (free tier M0 works)
- mongodb driver v6.0+
- mongoose v8.0+ (alternative ORM approach)
- Next.js 14+ (for Next.js example) or AWS Lambda
- TypeScript 5+
- dotenv for environment variables
Step-by-Step Implementation
Step 1: Setup
Option A: Next.js Setup
npx create-next-app@latest mongodb-serverless --typescript --app
cd mongodb-serverless
npm install mongodb mongoose
Option B: Standalone Node.js/Lambda Setup
mkdir mongodb-serverless
cd mongodb-serverless
npm init -y
npm install mongodb mongoose dotenv
npm install -D typescript @types/node tsx
Initialize TypeScript:
npx tsc --init
Create project structure:
mkdir -p lib/mongodb utils
touch lib/mongodb/client.ts lib/mongodb/mongoose.ts utils/health.ts
Step 2: Configuration
2.1: Set Up MongoDB Atlas
- Create cluster at MongoDB Atlas
- Go to Database Access → Add User
- Go to Network Access → Add IP Address → Allow Access from Anywhere (0.0.0.0/0)
- Get connection string from Connect → Drivers
2.2: Configure Environment Variables
Create .env.local (Next.js) or .env:
# .env.local
# MongoDB Atlas Connection String
MONGODB_URI=mongodb+srv://username:password@cluster.mongodb.net/database?retryWrites=true&w=majority
# Connection Pool Configuration
MONGODB_MAX_POOL_SIZE=10
MONGODB_MIN_POOL_SIZE=2
MONGODB_MAX_IDLE_TIME_MS=30000
MONGODB_CONNECT_TIMEOUT_MS=10000
MONGODB_SOCKET_TIMEOUT_MS=45000
# Serverless-specific
MONGODB_SERVER_SELECTION_TIMEOUT_MS=5000
For production, use smaller pool sizes:
# Production - Serverless Optimized
MONGODB_MAX_POOL_SIZE=5
MONGODB_MIN_POOL_SIZE=1
Add to .gitignore:
echo ".env.local" >> .gitignore
echo ".env" >> .gitignore
Step 3: Core Logic
3.1: MongoDB Native Driver Connection (Recommended for Serverless)
Create lib/mongodb/client.ts:
// lib/mongodb/client.ts
import { MongoClient, MongoClientOptions, Db } from 'mongodb';
if (!process.env.MONGODB_URI) {
throw new Error('Please add your MongoDB URI to .env.local');
}
const uri = process.env.MONGODB_URI;
// Connection pool configuration optimized for serverless
const options: MongoClientOptions = {
maxPoolSize: parseInt(process.env.MONGODB_MAX_POOL_SIZE || '10'),
minPoolSize: parseInt(process.env.MONGODB_MIN_POOL_SIZE || '2'),
maxIdleTimeMS: parseInt(process.env.MONGODB_MAX_IDLE_TIME_MS || '30000'),
connectTimeoutMS: parseInt(process.env.MONGODB_CONNECT_TIMEOUT_MS || '10000'),
socketTimeoutMS: parseInt(process.env.MONGODB_SOCKET_TIMEOUT_MS || '45000'),
serverSelectionTimeoutMS: parseInt(process.env.MONGODB_SERVER_SELECTION_TIMEOUT_MS || '5000'),
// Recommended serverless settings
retryWrites: true,
retryReads: true,
compressors: ['zlib'], // Reduce bandwidth
};
/**
* Global cached connection for serverless environments
* Prevents connection pool exhaustion across function invocations
*/
interface CachedConnection {
client: MongoClient | null;
promise: Promise<MongoClient> | null;
}
// Use global to persist across hot reloads in development
declare global {
var _mongoClientPromise: Promise<MongoClient> | undefined;
}
let cached: CachedConnection = {
client: null,
promise: null,
};
/**
* Get singleton MongoDB client
*/
export async function getMongoClient(): Promise<MongoClient> {
// Return cached client if available
if (cached.client) {
return cached.client;
}
// Return in-progress connection if connecting
if (cached.promise) {
return cached.promise;
}
// Create new connection
cached.promise = MongoClient.connect(uri, options)
.then((client) => {
console.log('MongoDB connected successfully');
cached.client = client;
return client;
})
.catch((error) => {
console.error('MongoDB connection error:', error);
cached.promise = null; // Reset promise on error
throw error;
});
return cached.promise;
}
/**
* Get database instance
*/
export async function getDatabase(dbName?: string): Promise<Db> {
const client = await getMongoClient();
return client.db(dbName);
}
/**
* Health check for connection
*/
export async function checkConnection(): Promise<boolean> {
try {
const client = await getMongoClient();
await client.db('admin').command({ ping: 1 });
return true;
} catch (error) {
console.error('MongoDB health check failed:', error);
return false;
}
}
/**
* Graceful shutdown (for non-serverless environments)
*/
export async function closeConnection(): Promise<void> {
if (cached.client) {
await cached.client.close();
cached.client = null;
cached.promise = null;
console.log('MongoDB connection closed');
}
}
// In development, use global variable to preserve connection across hot reloads
if (process.env.NODE_ENV === 'development') {
if (!global._mongoClientPromise) {
global._mongoClientPromise = cached.promise;
}
cached.promise = global._mongoClientPromise;
}
// Export for Next.js compatibility
export const clientPromise = getMongoClient();
3.2: Mongoose Connection (Alternative ORM Approach)
Create lib/mongodb/mongoose.ts:
// lib/mongodb/mongoose.ts
import mongoose from 'mongoose';
if (!process.env.MONGODB_URI) {
throw new Error('Please add your MongoDB URI to .env.local');
}
const MONGODB_URI = process.env.MONGODB_URI;
/**
* Global cached connection for Mongoose
*/
interface CachedMongoose {
conn: typeof mongoose | null;
promise: Promise<typeof mongoose> | null;
}
declare global {
var mongoose: CachedMongoose | undefined;
}
let cached: CachedMongoose = global.mongoose || {
conn: null,
promise: null,
};
if (!global.mongoose) {
global.mongoose = cached;
}
/**
* Connect to MongoDB using Mongoose
*/
export async function connectMongoose(): Promise<typeof mongoose> {
// Return existing connection
if (cached.conn) {
return cached.conn;
}
// Return in-progress connection
if (cached.promise) {
return cached.promise;
}
// Configure Mongoose for serverless
mongoose.set('strictQuery', false);
const opts = {
maxPoolSize: parseInt(process.env.MONGODB_MAX_POOL_SIZE || '10'),
minPoolSize: parseInt(process.env.MONGODB_MIN_POOL_SIZE || '2'),
maxIdleTimeMS: parseInt(process.env.MONGODB_MAX_IDLE_TIME_MS || '30000'),
connectTimeoutMS: parseInt(process.env.MONGODB_CONNECT_TIMEOUT_MS || '10000'),
socketTimeoutMS: parseInt(process.env.MONGODB_SOCKET_TIMEOUT_MS || '45000'),
serverSelectionTimeoutMS: parseInt(process.env.MONGODB_SERVER_SELECTION_TIMEOUT_MS || '5000'),
bufferCommands: false, // Disable Mongoose buffering in serverless
};
// Create new connection
cached.promise = mongoose
.connect(MONGODB_URI, opts)
.then((mongooseInstance) => {
console.log('Mongoose connected successfully');
return mongooseInstance;
})
.catch((error) => {
console.error('Mongoose connection error:', error);
cached.promise = null;
throw error;
});
cached.conn = await cached.promise;
return cached.conn;
}
/**
* Disconnect Mongoose (for cleanup)
*/
export async function disconnectMongoose(): Promise<void> {
if (cached.conn) {
await cached.conn.disconnect();
cached.conn = null;
cached.promise = null;
console.log('Mongoose disconnected');
}
}
/**
* Health check
*/
export async function checkMongooseConnection(): Promise<boolean> {
try {
const mongooseInstance = await connectMongoose();
return mongooseInstance.connection.readyState === 1; // 1 = connected
} catch (error) {
console.error('Mongoose health check failed:', error);
return false;
}
}
3.3: Connection Health Check Utility
Create utils/health.ts:
// utils/health.ts
import { getMongoClient } from '../lib/mongodb/client';
export interface ConnectionHealth {
isHealthy: boolean;
connectionCount: number;
maxPoolSize: number;
availableConnections: number;
uptime: number | null;
lastCheck: Date;
}
/**
* Get detailed connection pool health metrics
*/
export async function getConnectionHealth(): Promise<ConnectionHealth> {
try {
const client = await getMongoClient();
const adminDb = client.db('admin');
// Get server status
const serverStatus = await adminDb.command({ serverStatus: 1 });
const poolSize = parseInt(process.env.MONGODB_MAX_POOL_SIZE || '10');
return {
isHealthy: true,
connectionCount: serverStatus.connections?.current || 0,
maxPoolSize: poolSize,
availableConnections: serverStatus.connections?.available || 0,
uptime: serverStatus.uptime || null,
lastCheck: new Date(),
};
} catch (error) {
console.error('Health check failed:', error);
return {
isHealthy: false,
connectionCount: 0,
maxPoolSize: parseInt(process.env.MONGODB_MAX_POOL_SIZE || '10'),
availableConnections: 0,
uptime: null,
lastCheck: new Date(),
};
}
}
/**
* Monitor connection pool and log warnings
*/
export async function monitorConnectionPool(): Promise<void> {
const health = await getConnectionHealth();
if (!health.isHealthy) {
console.error('⚠️ MongoDB connection unhealthy');
return;
}
const utilizationPercent = (health.connectionCount / health.maxPoolSize) * 100;
if (utilizationPercent > 80) {
console.warn(
`⚠️ High connection pool utilization: ${utilizationPercent.toFixed(1)}% (${health.connectionCount}/${health.maxPoolSize})`
);
}
if (health.availableConnections < 5) {
console.warn(
`⚠️ Low available connections: ${health.availableConnections}`
);
}
console.log(
`✓ MongoDB pool: ${health.connectionCount}/${health.maxPoolSize} connections, ${health.availableConnections} available`
);
}
3.4: Next.js API Route Example
Create app/api/users/route.ts:
// app/api/users/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { getDatabase } from '@/lib/mongodb/client';
export async function GET(request: NextRequest) {
try {
const db = await getDatabase();
const users = await db.collection('users').find({}).limit(10).toArray();
return NextResponse.json({
success: true,
count: users.length,
users,
});
} catch (error: any) {
console.error('API error:', error);
return NextResponse.json(
{
success: false,
error: error.message || 'Failed to fetch users',
},
{ status: 500 }
);
}
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const db = await getDatabase();
const result = await db.collection('users').insertOne({
...body,
createdAt: new Date(),
});
return NextResponse.json({
success: true,
id: result.insertedId,
}, { status: 201 });
} catch (error: any) {
console.error('API error:', error);
return NextResponse.json(
{
success: false,
error: error.message || 'Failed to create user',
},
{ status: 500 }
);
}
}
3.5: Health Check Endpoint
Create app/api/health/route.ts:
// app/api/health/route.ts
import { NextResponse } from 'next/server';
import { checkConnection } from '@/lib/mongodb/client';
import { getConnectionHealth } from '@/utils/health';
export async function GET() {
try {
const isConnected = await checkConnection();
const health = await getConnectionHealth();
return NextResponse.json({
status: isConnected ? 'healthy' : 'unhealthy',
timestamp: new Date().toISOString(),
mongodb: {
connected: isConnected,
...health,
},
}, {
status: isConnected ? 200 : 503,
});
} catch (error: any) {
return NextResponse.json({
status: 'unhealthy',
error: error.message,
timestamp: new Date().toISOString(),
}, {
status: 503,
});
}
}
3.6: AWS Lambda Handler Example
Create lambda/handler.ts:
// lambda/handler.ts
import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
import { getDatabase, closeConnection } from '../lib/mongodb/client';
/**
* Lambda handler with proper connection management
*/
export const handler = async (
event: APIGatewayProxyEvent
): Promise<APIGatewayProxyResult> => {
try {
// Get database connection
const db = await getDatabase();
// Perform database operation
const users = await db.collection('users').find({}).limit(10).toArray();
return {
statusCode: 200,
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
success: true,
count: users.length,
users,
}),
};
} catch (error: any) {
console.error('Lambda error:', error);
return {
statusCode: 500,
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
success: false,
error: error.message,
}),
};
}
// Note: DO NOT close connection in Lambda
// Let Lambda runtime reuse connections across invocations
};
// Optional: Graceful shutdown hook (AWS Lambda Extensions)
process.on('SIGTERM', async () => {
console.log('SIGTERM received, closing MongoDB connection');
await closeConnection();
});
3.7: Middleware for Connection Monitoring
Create middleware/mongodb-monitor.ts:
// middleware/mongodb-monitor.ts
import { NextRequest, NextResponse } from 'next/server';
import { monitorConnectionPool } from '@/utils/health';
/**
* Middleware to monitor connection pool on each request
*/
export async function mongodbMonitorMiddleware(request: NextRequest) {
// Monitor pool before request
if (process.env.NODE_ENV === 'development') {
await monitorConnectionPool();
}
const response = NextResponse.next();
// Add custom headers for monitoring
response.headers.set('X-MongoDB-Pool-Monitored', 'true');
return response;
}
// Optionally export as Next.js middleware
export const config = {
matcher: '/api/:path*',
};
3.8: Connection Pool Stress Test
Create scripts/stress-test.ts:
// scripts/stress-test.ts
import { getDatabase } from '../lib/mongodb/client';
import { getConnectionHealth } from '../utils/health';
async function stressTest(concurrentRequests: number = 50) {
console.log(`Starting stress test with ${concurrentRequests} concurrent requests...\n`);
const startTime = Date.now();
// Create concurrent requests
const requests = Array.from({ length: concurrentRequests }, async (_, i) => {
try {
const db = await getDatabase();
const result = await db.collection('users').findOne({});
return { success: true, index: i };
} catch (error: any) {
return { success: false, index: i, error: error.message };
}
});
// Execute all requests
const results = await Promise.all(requests);
const duration = Date.now() - startTime;
const successful = results.filter((r) => r.success).length;
const failed = results.filter((r) => !r.success).length;
console.log('\n--- Stress Test Results ---');
console.log(`Total requests: ${concurrentRequests}`);
console.log(`Successful: ${successful}`);
console.log(`Failed: ${failed}`);
console.log(`Duration: ${duration}ms`);
console.log(`Avg: ${(duration / concurrentRequests).toFixed(2)}ms per request\n`);
// Check connection health
const health = await getConnectionHealth();
console.log('--- Connection Pool Health ---');
console.log(`Connections: ${health.connectionCount}/${health.maxPoolSize}`);
console.log(`Available: ${health.availableConnections}`);
console.log(`Healthy: ${health.isHealthy ? 'Yes' : 'No'}\n`);
if (failed > 0) {
console.error('Failed requests:', results.filter((r) => !r.success));
}
}
// Run test
stressTest(100).catch(console.error);
Run test:
npx tsx scripts/stress-test.ts
Step 4: Testing
4.1: Start Development Server
For Next.js:
npm run dev
For standalone:
npx tsx src/index.ts
4.2: Test Basic Connection
curl http://localhost:3000/api/health
Expected response:
{
"status": "healthy",
"timestamp": "2026-03-13T...",
"mongodb": {
"connected": true,
"isHealthy": true,
"connectionCount": 2,
"maxPoolSize": 10,
"availableConnections": 498,
"uptime": 12345
}
}
4.3: Test CRUD Operations
Create user:
curl -X POST http://localhost:3000/api/users \
-H "Content-Type: application/json" \
-d '{"name": "John Doe", "email": "john@example.com"}'
Get users:
curl http://localhost:3000/api/users
4.4: Test Connection Pool Under Load
Install Apache Bench or use wrk:
# Install Apache Bench
brew install apache-bench # macOS
sudo apt-get install apache2-utils # Linux
# Run load test (100 requests, 10 concurrent)
ab -n 100 -c 10 http://localhost:3000/api/users
Monitor logs for connection pool warnings.
4.5: Test Cold Start Performance
Simulate serverless cold start:
# Restart server to clear cache
# Kill and restart
# Measure first request time
time curl http://localhost:3000/api/users
First request should be slower (connection establishment), subsequent requests faster (connection reuse).
4.6: Verify Connection Reuse
Add logging to client.ts:
cached.promise = MongoClient.connect(uri, options)
.then((client) => {
console.log('✓ NEW MongoDB connection established');
cached.client = client;
return client;
})
Make multiple requests:
for i in {1..5}; do
curl http://localhost:3000/api/users
sleep 1
done
Should only see “NEW MongoDB connection established” once.
Common Errors & Troubleshooting
1. Error: “MongoServerError: Too many connections”
Cause: Connection pool size exceeds MongoDB Atlas tier limit, or connections aren’t being reused properly.
Fix: Reduce pool size and verify singleton pattern:
// Verify you're using singleton correctly
// BAD - Creates new connection every time
export async function getDatabase() {
const client = await MongoClient.connect(uri, options); // Don't do this!
return client.db();
}
// GOOD - Reuses cached connection
let cached = { client: null, promise: null };
export async function getDatabase() {
if (cached.client) return cached.client.db();
if (!cached.promise) {
cached.promise = MongoClient.connect(uri, options);
}
cached.client = await cached.promise;
return cached.client.db();
}
Configure for Atlas tier:
# M0 Free Tier - 100 connection limit
MONGODB_MAX_POOL_SIZE=5
MONGODB_MIN_POOL_SIZE=1
# M2 - 500 connection limit
MONGODB_MAX_POOL_SIZE=10
MONGODB_MIN_POOL_SIZE=2
# M10+ - Higher limits
MONGODB_MAX_POOL_SIZE=20
MONGODB_MIN_POOL_SIZE=5
Monitor active connections:
// Add to health check
const db = await getDatabase();
const adminDb = db.admin();
const status = await adminDb.command({ serverStatus: 1 });
console.log('Current connections:', status.connections.current);
console.log('Available connections:', status.connections.available);
if (status.connections.current > 80) {
console.warn('⚠️ Connection limit near capacity!');
}
2. Error: “MongoTimeoutError: Server selection timed out” in Lambda
Cause: Lambda cold starts combined with slow DNS resolution or VPC configuration blocking MongoDB Atlas access.
Fix: Optimize connection settings for Lambda:
const options: MongoClientOptions = {
// Reduce timeouts for Lambda
serverSelectionTimeoutMS: 5000, // 5 seconds max
connectTimeoutMS: 10000,
// Enable keep-alive
keepAlive: true,
keepAliveInitialDelay: 300000, // 5 minutes
// Retry logic
retryWrites: true,
retryReads: true,
// DNS optimization
directConnection: false, // Use SRV for better resolution
};
For Lambda in VPC, ensure NAT Gateway or VPC Endpoints configured:
# serverless.yml
functions:
myFunction:
handler: handler.handler
vpc:
securityGroupIds:
- sg-xxxxxx
subnetIds:
- subnet-xxxxx # Must be private subnet with NAT
Pre-warm connections in Lambda:
// At top level (outside handler)
let clientPromise: Promise<MongoClient> | null = null;
export const handler = async (event) => {
// Initiate connection before handler if not connected
if (!clientPromise) {
clientPromise = MongoClient.connect(uri, options);
}
const client = await clientPromise;
const db = client.db();
// Your handler logic...
};
3. Error: “Topology was destroyed” on hot reload
Cause: Next.js hot module replacement (HMR) destroys connection but cached reference persists.
Fix: Implement proper cleanup and reload detection:
// lib/mongodb/client.ts
let cached: CachedConnection = {
client: null,
promise: null,
};
// Detect HMR in development
if (process.env.NODE_ENV === 'development') {
// Use global to persist across reloads
if (!global._mongoClientPromise) {
global._mongoClientPromise = cached.promise;
} else {
// Reuse existing promise
cached.promise = global._mongoClientPromise;
}
}
// Add connection validation before returning
export async function getMongoClient(): Promise<MongoClient> {
if (cached.client) {
try {
// Verify connection is still valid
await cached.client.db('admin').command({ ping: 1 });
return cached.client;
} catch (error) {
// Connection is stale, reset
console.warn('Stale connection detected, reconnecting...');
cached.client = null;
cached.promise = null;
}
}
if (!cached.promise) {
cached.promise = MongoClient.connect(uri, options);
}
cached.client = await cached.promise;
return cached.client;
}
Add cleanup on process exit:
// In Next.js pages/_app.tsx or app/layout.tsx
if (typeof window === 'undefined') {
process.on('SIGTERM', async () => {
await closeConnection();
});
process.on('SIGINT', async () => {
await closeConnection();
});
}
Security Checklist
- Never commit connection strings with credentials to version control
- Use environment variables for all MongoDB URIs and credentials
- Enable IP whitelist in Atlas Network Access (don’t use 0.0.0.0/0 in production)
- Use VPC peering or private endpoints for production Lambda/Vercel deployments
- Rotate database passwords regularly
- Enable authentication on MongoDB (enabled by default on Atlas)
- Use connection string encryption in environment variables
- Implement connection timeout limits to prevent hanging connections
- Monitor connection pool metrics in production
- Set up Atlas alerts for connection spikes and CPU usage
- Use read replicas for read-heavy workloads to distribute connections
- Implement rate limiting on API routes to prevent connection exhaustion
- Enable MongoDB audit logs for production security monitoring
- Use separate database users for different services with minimal permissions
- Encrypt data at rest in Atlas (enabled by default on M10+)
- Enable TLS/SSL for all connections (enforced by Atlas connection strings)
- Set maxIdleTimeMS to close idle connections and free pool space
- Implement connection health checks before executing queries
- Log connection errors securely without exposing credentials
- Use MongoDB Atlas Data API for serverless edge functions when appropriate
Related Resources
For implementing complete authentication with MongoDB, see our guide on API authentication methods compared covering JWT storage patterns.
When building scalable APIs with MongoDB, check our tutorial on designing REST APIs for SaaS applications for schema design patterns.
For implementing usage-based pricing that tracks API calls in MongoDB, explore our API rate limiting strategies guide covering distributed rate limiting.
When choosing between database architectures, our GraphQL vs REST comparison helps evaluate query patterns with MongoDB.
For teams using vector databases alongside MongoDB, see our Pinecone integration guide covering hybrid data architectures.

Finly Insights Team is a group of software developers, cloud engineers, and technical writers with real hands-on experience in the tech industry. We specialize in cloud computing, cybersecurity, SaaS tools, AI automation, and API development. Every article we publish is thoroughly researched, written, and reviewed by people who have actually worked in these fields.




