Overview
In our Next.js application, we encountered connection pool exhaustion issues during development hot reloads because each page was creating its own PrismaClient instance. This article explains how we resolved this problem by implementing a proper singleton pattern and optimizing connection pool settings for Neon Database.
Tech Stack
- Next.js 14+ (App Router)
- Prisma ORM (v5.x)
- PostgreSQL (Neon Database)
- TypeScript (v5.x)
- Singleton Pattern
- Edge Runtime Support
Background & Challenges
Problem Discovery
We were experiencing the following issues in our development environment:
// Each page was creating its own instance (BAD practice)
// app/dashboard/page.tsx
export default async function DashboardPage() {
const prisma = new PrismaClient(); // ❌ Creates new instance every time
const customers = await prisma.customer.findMany();
// ...
}
// app/emails/[id]/page.tsx
export default async function EmailDetailPage() {
const prisma = new PrismaClient(); // ❌ Creates new instance every time
const email = await prisma.email.findUnique();
// ...
}
Problems encountered:
- New PrismaClient instances were created on every hot reload
- Old instances weren't properly disposed, leading to connection pool exhaustion
- Slow connections after idle periods
- Unnecessary connections in production environment
Error Messages
Error: Can't reach database server at `xxx.xxx.xxx.xxx:5432`
Please make sure your database server is running at `xxx.xxx.xxx.xxx:5432`.
Prisma Client could not connect to the database.
Performance Measurements
Connection time comparison:
- Before singleton: Initial access 500ms, subsequent accesses 300-400ms
- After singleton: Initial access 200ms, subsequent accesses 50-100ms
Implementation
1. Implementing the Singleton Pattern
We implemented a proper singleton pattern in packages/database/src/index.ts:
// packages/database/src/index.ts
import { PrismaClient } from '@prisma/client';
// Global variable type definition
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
// Create or retrieve PrismaClient instance
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log: process.env.NODE_ENV === 'development'
? ['query', 'error', 'warn']
: ['error'],
datasources: {
db: {
url: process.env.DATABASE_URL,
},
},
});
// Save globally only in development (hot reload support)
if (process.env.NODE_ENV !== 'production') {
globalForPrisma.prisma = prisma;
}
// Re-export types
export * from '@prisma/client';
Key Points:
-
Leveraging globalThis
- globalThis persists even when variables are reset during Next.js hot reloads
- Prevents unnecessary instance creation in development environment
-
Environment-specific Handling
- production: Creates new instance each time (serverless environment support)
- development: Saves globally (hot reload support)
-
Log Level Configuration
- development: Outputs query logs for improved development efficiency
- production: Error logs only for optimal production performance
2. Optimizing Connection Pool Settings
We added connection pool settings for Neon Database in .env:
# .env.example
# Connection pool settings for Neon Database
DATABASE_URL="postgresql://user:password@host/dbname?connection_limit=10&pool_timeout=10&connect_timeout=10"
# Parameter descriptions:
# - connection_limit: Maximum number of connections (Neon free plan allows up to 10)
# - pool_timeout: Timeout for acquiring connection from pool (seconds)
# - connect_timeout: Database connection timeout (seconds)
Connection Count Design:
- Neon free plan: Maximum 10 connections
- Recommended setting:
connection_limit=10 - Production environment: Adjust based on number of application instances
3. Standardizing Usage Across Pages
We updated all 7 files to use the shared instance:
// Before (BAD)
import { PrismaClient } from '@prisma/client';
export default async function Page() {
const prisma = new PrismaClient(); // ❌
// ...
}
// After (GOOD)
import { prisma } from '@myapp/database'; // ✅
export default async function Page() {
// Use shared instance directly
const customers = await prisma.customer.findMany();
// ...
}
Modified Files:
app/dashboard/page.tsxapp/customers/page.tsxapp/customers/[id]/page.tsxapp/emails/[id]/page.tsxapp/auth/signin/page.tsxapp/layout.tsxapp/api/attachments/[attachmentId]/download/route.ts
4. Explicitly Setting Next.js Cache Strategy
To ensure data consistency, we explicitly set dynamic rendering:
// app/dashboard/page.tsx
export const dynamic = 'force-dynamic'; // ✅ Disable caching
export default async function DashboardPage() {
const customers = await prisma.customer.findMany();
// Always fetch latest data
}
Cache Strategy Options:
-
force-dynamic: Always use dynamic rendering (prioritize real-time data) -
force-static: Static generation (prioritize performance) -
auto: Next.js automatically determines (default)
Performance Improvement Results
Connection Time Improvements
| Scenario | Before | After | Improvement |
|---|---|---|---|
| Initial Access | 500ms | 200ms | 60% faster |
| Subsequent Accesses | 300-400ms | 50-100ms | 75% faster |
| After Hot Reload | 600ms | 150ms | 75% faster |
Connection Pool Status
Before:
Active connections: 15-20
Idle connections: 5-10
Total: 20-30 (significantly exceeding limit of 10)
→ Errors occurred
After:
Active connections: 3-5
Idle connections: 5-7
Total: 8-12 (within limit)
→ Stable operation
Memory Usage
- Before: ~10MB PrismaClient per page
- After: ~10MB shared instance (fixed)
- Memory reduction: ~70-80MB (across 8 pages)
Edge Runtime Support
Constraints and Solutions for Edge Environment
Considerations when using Prisma in Next.js Edge Runtime:
// Use Prisma Adapter for Edge Runtime
import { PrismaClient } from '@prisma/client/edge';
import { withAccelerate } from '@prisma/extension-accelerate';
// Edge environment configuration
export const prismaEdge = new PrismaClient({
datasourceUrl: process.env.DATABASE_URL
}).$extends(withAccelerate());
// Usage example (Edge API Route)
export const runtime = 'edge';
export async function GET() {
const users = await prismaEdge.user.findMany({
cacheStrategy: { ttl: 60 } // Leverage Accelerate caching
});
return Response.json(users);
}
Edge Runtime Selection Criteria:
- Global distribution needed → Edge Runtime
- Complex database operations → Node.js Runtime
- Real-time priority → Node.js Runtime
- Scalability priority → Edge Runtime
Memory Leak Prevention
Proper $disconnect() Invocation
// Implementing graceful shutdown
import { prisma } from '@myapp/database';
// Cleanup on process termination
async function gracefulShutdown() {
console.log('Gracefully shutting down...');
try {
await prisma.$disconnect();
console.log('Database connections closed');
} catch (error) {
console.error('Error during shutdown:', error);
}
process.exit(0);
}
// Signal handling
process.on('SIGTERM', gracefulShutdown);
process.on('SIGINT', gracefulShutdown);
// Proper handling within Next.js API Routes
export async function GET(request: Request) {
try {
const data = await prisma.user.findMany();
return Response.json(data);
} catch (error) {
// Ensure connection cleanup even on errors
if (error instanceof Error && error.message.includes('P2024')) {
// When connection pool is full
await prisma.$disconnect();
await new Promise(resolve => setTimeout(resolve, 100));
// Retry or return error
}
throw error;
}
}
Monitoring Memory Usage
// Memory monitoring and alerts
setInterval(async () => {
const memUsage = process.memoryUsage();
// Display memory usage in MB
const report = {
rss: Math.round(memUsage.rss / 1024 / 1024),
heapTotal: Math.round(memUsage.heapTotal / 1024 / 1024),
heapUsed: Math.round(memUsage.heapUsed / 1024 / 1024),
external: Math.round(memUsage.external / 1024 / 1024)
};
console.log('Memory usage:', report);
// Alert if threshold exceeded
if (report.heapUsed > 500) { // Over 500MB
console.warn('High memory usage detected!');
// Reset connections if necessary
await prisma.$disconnect();
await prisma.$connect();
}
}, 60000); // Every 1 minute
Connection Pool Monitoring
Leveraging Prisma.$metrics
// packages/database/src/monitoring.ts
import { prisma } from './index';
export async function enableMetrics() {
// Enable metrics
await prisma.$metrics.json();
}
export async function getPoolMetrics() {
const metrics = await prisma.$metrics.json();
// Extract connection pool related metrics
const poolMetrics = {
activeConnections: 0,
idleConnections: 0,
waitingClients: 0,
totalQueries: 0
};
metrics.counters.forEach(counter => {
switch (counter.key) {
case 'prisma_pool_connections_open':
poolMetrics.activeConnections = counter.value;
break;
case 'prisma_pool_connections_idle':
poolMetrics.idleConnections = counter.value;
break;
case 'prisma_pool_connections_busy':
poolMetrics.waitingClients = counter.value;
break;
case 'prisma_client_queries_total':
poolMetrics.totalQueries = counter.value;
break;
}
});
return poolMetrics;
}
// Periodic monitoring
export function startPoolMonitoring() {
setInterval(async () => {
const metrics = await getPoolMetrics();
console.log('Connection Pool Status:', {
...metrics,
timestamp: new Date().toISOString()
});
// Send to CloudWatch or Datadog
await sendToMonitoring(metrics);
// Check alert conditions
if (metrics.activeConnections > 8) {
await sendAlert({
level: 'WARNING',
message: `High connection count: ${metrics.activeConnections}/10`,
metrics
});
}
}, 30000); // Every 30 seconds
}
Dashboard Example
// app/api/admin/metrics/route.ts
import { NextResponse } from 'next/server';
import { getPoolMetrics } from '@myapp/database/monitoring';
export async function GET() {
try {
const metrics = await getPoolMetrics();
const health = {
status: metrics.activeConnections < 8 ? 'healthy' : 'warning',
database: {
connections: {
active: metrics.activeConnections,
idle: metrics.idleConnections,
total: metrics.activeConnections + metrics.idleConnections,
limit: 10
},
queries: {
total: metrics.totalQueries,
qps: calculateQPS(metrics.totalQueries)
}
},
timestamp: new Date().toISOString()
};
return NextResponse.json(health);
} catch (error) {
return NextResponse.json(
{ error: 'Failed to fetch metrics' },
{ status: 500 }
);
}
}
Transaction Management Considerations
Proper Transaction Handling
// Connection management within transactions
export async function transferCredits(
fromUserId: string,
toUserId: string,
amount: number
) {
// Using Interactive Transaction
return await prisma.$transaction(async (tx) => {
// 1. Check sender's balance
const sender = await tx.user.findUnique({
where: { id: fromUserId },
select: { credits: true }
});
if (!sender || sender.credits < amount) {
throw new Error('Insufficient credits');
}
// 2. Deduct from sender
await tx.user.update({
where: { id: fromUserId },
data: { credits: { decrement: amount } }
});
// 3. Add to recipient
await tx.user.update({
where: { id: toUserId },
data: { credits: { increment: amount } }
});
// 4. Record transaction log
const log = await tx.transactionLog.create({
data: {
fromUserId,
toUserId,
amount,
timestamp: new Date()
}
});
return log;
}, {
maxWait: 5000, // Maximum wait time before transaction starts
timeout: 10000, // Transaction timeout
isolationLevel: 'Serializable' // Isolation level
});
}
// With error handling
export async function safeTransfer(
fromUserId: string,
toUserId: string,
amount: number
) {
let retries = 3;
while (retries > 0) {
try {
return await transferCredits(fromUserId, toUserId, amount);
} catch (error) {
if (error instanceof Error) {
// Retry on deadlock or timeout
if (error.message.includes('P2034') || error.message.includes('P2024')) {
retries--;
console.log(`Retrying transaction... (${retries} attempts left)`);
await new Promise(resolve => setTimeout(resolve, 1000));
} else {
throw error; // Throw other errors immediately
}
}
}
}
throw new Error('Transaction failed after maximum retries');
}
Handling Test Environments
Mocking with Jest
// __tests__/setup.ts
import { PrismaClient } from '@prisma/client';
import { mockDeep, mockReset, DeepMockProxy } from 'jest-mock-extended';
// Create Prisma Client mock
export const prismaMock = mockDeep<PrismaClient>() as unknown as DeepMockProxy<PrismaClient>;
// jest.setup.js
jest.mock('@myapp/database', () => ({
__esModule: true,
prisma: prismaMock
}));
// Test file example
import { prismaMock } from './setup';
import { getUserById } from '../services/user';
describe('User Service', () => {
beforeEach(() => {
mockReset(prismaMock);
});
it('should get user by id', async () => {
const mockUser = {
id: '1',
name: 'Test User',
email: 'test@example.com'
};
prismaMock.user.findUnique.mockResolvedValue(mockUser);
const user = await getUserById('1');
expect(user).toEqual(mockUser);
expect(prismaMock.user.findUnique).toHaveBeenCalledWith({
where: { id: '1' }
});
});
});
Integration Test Setup
// __tests__/integration/setup.ts
import { PrismaClient } from '@prisma/client';
import { execSync } from 'child_process';
// Test database URL
const TEST_DATABASE_URL = process.env.TEST_DATABASE_URL || 'postgresql://test:test@localhost:5432/test';
// Test Prisma client
export const testPrisma = new PrismaClient({
datasources: {
db: {
url: TEST_DATABASE_URL
}
}
});
// Pre-test setup
export async function setupTestDatabase() {
// Run migrations
execSync('npx prisma migrate deploy', {
env: { ...process.env, DATABASE_URL: TEST_DATABASE_URL }
});
// Insert seed data
await testPrisma.user.createMany({
data: [
{ name: 'Alice', email: 'alice@test.com' },
{ name: 'Bob', email: 'bob@test.com' }
]
});
}
// Post-test cleanup
export async function teardownTestDatabase() {
// Delete data (considering foreign key constraints)
await testPrisma.transactionLog.deleteMany();
await testPrisma.user.deleteMany();
// Close connection
await testPrisma.$disconnect();
}
Operational Considerations
Deployment Considerations
# .github/workflows/deploy.yml
name: Deploy with Zero Downtime
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Run migrations
run: |
# Blue-Green deployment support
npx prisma migrate deploy
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
- name: Health check before deployment
run: |
# Check connection pool status
curl https://api.example.com/api/admin/metrics
- name: Deploy to production
run: |
# Rolling deployment
kubectl set image deployment/api api=myapp:${{ github.sha }}
kubectl rollout status deployment/api
- name: Post-deployment verification
run: |
# Check connection pool after deployment
sleep 30
curl https://api.example.com/api/admin/metrics
Monitoring and Alert Settings
// monitoring/alerts.ts
export const alertRules = [
{
name: 'high_connection_count',
condition: 'connections.active > 8',
severity: 'warning',
action: 'notify_slack'
},
{
name: 'connection_pool_exhausted',
condition: 'connections.active >= 10',
severity: 'critical',
action: 'page_oncall'
},
{
name: 'slow_queries',
condition: 'query.duration > 1000',
severity: 'warning',
action: 'log_to_dashboard'
}
];
Troubleshooting
Common Issues and Solutions
1. "Too many connections" Error
Symptom: Database connection count reaches limit
Solution:
// Connection reset script
async function resetConnections() {
console.log('Resetting all database connections...');
// Disconnect all existing connections
await prisma.$disconnect();
// Wait briefly
await new Promise(resolve => setTimeout(resolve, 1000));
// Establish new connection
await prisma.$connect();
console.log('Connections reset successfully');
}
// Integrate into error handler
app.use(async (error, req, res, next) => {
if (error.code === 'P2024') {
await resetConnections();
// Retry or return error response
}
next(error);
});
2. Memory Leak During Hot Reload
Symptom: Memory usage continues increasing in development environment
Solution:
// Cleanup for development environment
if (process.env.NODE_ENV === 'development') {
// HMR (Hot Module Replacement) cleanup
if (module.hot) {
module.hot.dispose(async () => {
await prisma.$disconnect();
});
}
}
3. Connection Errors in Serverless Environment
Symptom: Intermittent connection errors in Lambda functions
Solution:
// Connection management for Serverless
let prisma: PrismaClient;
export function getPrismaClient() {
if (!prisma) {
prisma = new PrismaClient({
datasources: {
db: {
url: process.env.DATABASE_URL + '?connection_limit=1' // Serverless uses 1 connection
}
}
});
}
return prisma;
}
// Lambda handler
export const handler = async (event: any) => {
const client = getPrismaClient();
try {
// Execute query
const result = await client.user.findMany();
return { statusCode: 200, body: JSON.stringify(result) };
} finally {
// Maintain connection for cold start optimization in Lambda
// await client.$disconnect(); // Don't disconnect
}
};
Technical Details
How globalThis Works
In Next.js development environment, modules are reloaded on file changes:
// Hot reload behavior
// First execution
const prisma = new PrismaClient(); // Create instance A
globalForPrisma.prisma = prisma; // Save globally
// File change (hot reload)
// Second execution
const prisma = globalForPrisma.prisma ?? new PrismaClient();
// → Reuse instance A saved globally
// → No new instance created
Behavior in Production Environment
In production, we consider serverless function characteristics:
if (process.env.NODE_ENV !== 'production') {
globalForPrisma.prisma = prisma;
}
// Don't save globally in production
// → Each Lambda/Edge function has its own instance
Reason:
- Serverless environments have independent memory space per function
- Global variables are not shared
- More efficient for each function to manage its own PrismaClient
Connection Pool Lifecycle
// Connection pool management
const prisma = new PrismaClient(); // Create pool (for 10 connections)
// 1. When executing query
await prisma.customer.findMany();
// → Acquire 1 connection from pool
// → Execute query
// → Return connection to pool
// 2. During idle time
// → Connections remain maintained waiting for next query
// → Timeout after connect_timeout
// 3. On application termination
await prisma.$disconnect();
// → Properly close all connections
Lessons Learned
Unexpected Pitfalls
-
Environment-specific Behavior in Next.js
- development: Modules reset on hot reload
- production: Independent per serverless function
- Design must accommodate both scenarios
-
PrismaClient Initialization Cost
- Instance creation takes 100-200ms
- Schema loading and validation
- Connection pool initialization
-
Neon Database Connection Limits
- Free plan: Maximum 10 connections
- Paid plans: Scalable
- Must consider during application design
Useful Knowledge for the Future
- Singleton Pattern Template
// Generic singleton pattern
const globalForX = globalThis as unknown as {
x: SomeClass | undefined;
};
export const x =
globalForX.x ??
new SomeClass(config);
if (process.env.NODE_ENV !== 'production') {
globalForX.x = x;
}
- Connection Pool Setting Best Practices
connection_limit = Total connections / Number of app instances
pool_timeout = Query timeout + margin
connect_timeout = Network latency + margin
- Gradual Migration Strategy
Phase 1: Create shared instance
Phase 2: Migrate one file at a time and test
Phase 3: Measure performance after migrating all files
Phase 4: Optimize connection pool settings
Better Implementation Discovered
Before (creating in each page):
// page1.tsx
const prisma = new PrismaClient();
// page2.tsx
const prisma = new PrismaClient();
// page3.tsx
const prisma = new PrismaClient();
// → 3 instances, 30 connections
After (shared instance):
// database/index.ts
export const prisma = new PrismaClient();
// page1.tsx
import { prisma } from '@myapp/database';
// page2.tsx
import { prisma } from '@myapp/database';
// page3.tsx
import { prisma } from '@myapp/database';
// → 1 instance, 10 connections
Conclusion
Implementing the PrismaClient singleton pattern was a highly effective approach for database connection optimization in Next.js environments. Key takeaways from this implementation:
- Environment-appropriate Implementation: Consider different behaviors in development/production
- Leveraging globalThis: Key point for hot reload support
- Connection Pool Settings: Optimization according to database plan
Particularly with Next.js App Router, where Server Components execute on each request, implementing the singleton pattern is extremely important. This enabled significant improvements in performance, memory usage, and connection count.
I recommend implementing the singleton pattern early when using Prisma with Next.js. This can prevent issues during hot reloads in development and connection pool exhaustion in production.
References
- Prisma Official Documentation - Best practices for using Prisma with Next.js
- Next.js Official Documentation - Database
- Neon Official Documentation - Connection Pooling
The code presented in this article is simplified from actual production code. In real implementations, additional considerations such as error handling and security checks are required.
Related Technologies: Next.js, Prisma ORM, PostgreSQL, Neon Database, TypeScript, Singleton Pattern, Connection Pool Optimization, Performance Tuning
Author: Development Team
Top comments (0)