jsmanifest logojsmanifest

Database Connection Pooling in Node.js

Database Connection Pooling in Node.js

Learn how to implement and optimize database connection pooling in Node.js to handle thousands of concurrent requests without crashing your application.

While I was looking over some Node.js applications the other day, I came across a particularly nasty production issue that had been plaguing a team for weeks. Their API would randomly crash under load, returning 500 errors to users during peak traffic. After some investigation, I realized they were creating a new database connection for every single request. Little did I know this was such a common mistake until I started consulting with other teams!

The culprit? No connection pooling. Every request was opening a fresh TCP connection to PostgreSQL, authenticating, and then closing it. Under light load, nobody noticed. But when traffic spiked, the database simply ran out of available connections and started refusing new ones.

I was once guilty of this exact same mistake early in my career. I thought, "Why do I need a pool? The database library handles everything for me!" Wonderful assumptions like that tend to bite you in production.

Why Connection Pooling Matters in Node.js

Here's the thing about database connections: they're expensive. Creating a new connection involves:

  • Establishing a TCP connection (network round trip)
  • Authentication handshake with the database
  • Setting up session state and configuration
  • Allocating memory on both client and server

This process can take anywhere from 10ms to 100ms depending on network latency and database load. When you multiply that by thousands of concurrent requests, you're adding significant overhead to every single operation.

Connection pooling solves this by maintaining a pool of established, ready-to-use connections. Instead of creating a new connection for each query, you borrow one from the pool, execute your query, and return it. The connection stays open, ready for the next request.

The performance difference is fascinating! In my tests, a simple query that took 50ms with connection-per-request dropped to just 2ms with proper pooling. That's a 25x improvement just by reusing connections.

Understanding Connection Pool Architecture

Think of a connection pool like a parking lot at a busy shopping mall. You have a fixed number of parking spots (connections), and shoppers (requests) come and go. When a shopper arrives, they take a spot if one's available. When they're done shopping, they free up the spot for someone else.

If all spots are full, new arrivals have two options: wait for a spot to open up, or leave (timeout). This is exactly how connection pools work in Node.js.

Connection pool architecture diagram

The pool manager handles:

  • Creating connections up to a maximum limit
  • Tracking which connections are in use vs idle
  • Queuing requests when all connections are busy
  • Removing stale or broken connections
  • Maintaining minimum idle connections for quick response

When I finally decided to implement proper pooling in my applications, I realized the configuration options were more important than I initially thought. Let's look at some real implementations.

Implementing Connection Pools: PostgreSQL and MongoDB

Here's a practical example using the popular pg library for PostgreSQL:

import { Pool } from 'pg';
 
// Connection pool configuration
const pool = new Pool({
  host: 'localhost',
  port: 5432,
  database: 'myapp',
  user: 'postgres',
  password: 'password',
  // Pool-specific settings
  max: 20,                    // Maximum number of connections
  min: 5,                     // Minimum idle connections to maintain
  idleTimeoutMillis: 30000,   // Close idle connections after 30 seconds
  connectionTimeoutMillis: 2000, // Wait 2 seconds for available connection
});
 
// BAD: This is what I used to do (don't do this!)
async function getUserBad(userId: number) {
  const client = await pool.connect(); // Gets connection from pool
  const result = await client.query('SELECT * FROM users WHERE id = $1', [userId]);
  // Oops! Forgot to release the connection back to the pool
  return result.rows[0];
}
 
// GOOD: Always release connections back to the pool
async function getUserGood(userId: number) {
  const client = await pool.connect();
  try {
    const result = await client.query('SELECT * FROM users WHERE id = $1', [userId]);
    return result.rows[0];
  } finally {
    client.release(); // Critical! Return connection to pool
  }
}
 
// BEST: For simple queries, use pool.query directly
async function getUserBest(userId: number) {
  const result = await pool.query('SELECT * FROM users WHERE id = $1', [userId]);
  return result.rows[0];
}

Notice the difference? The getUserBest function uses pool.query directly, which automatically checks out a connection, runs the query, and releases it back. This pattern eliminates the most common mistake: forgetting to release connections.

I cannot stress this enough! Connection leaks (forgetting to release) will eventually exhaust your pool and bring your application to a halt.

For MongoDB, the approach is similar but even more forgiving:

import { MongoClient } from 'mongodb';
 
const client = new MongoClient('mongodb://localhost:27017', {
  maxPoolSize: 50,           // Maximum connections
  minPoolSize: 10,           // Minimum idle connections
  maxIdleTimeMs: 30000,      // Close idle connections after 30s
  serverSelectionTimeoutMS: 5000, // Timeout for server selection
  socketTimeoutMS: 45000,    // Socket timeout
});
 
await client.connect();
 
// MongoDB handles connection checkout/release automatically
async function getUser(userId: string) {
  const db = client.db('myapp');
  const user = await db.collection('users').findOne({ _id: userId });
  // No need to manually release - MongoDB driver handles it!
  return user;
}
 
// For transactions, you still need explicit sessions
async function transferMoney(fromId: string, toId: string, amount: number) {
  const session = client.startSession();
  try {
    await session.withTransaction(async () => {
      const db = client.db('myapp');
      await db.collection('accounts').updateOne(
        { _id: fromId },
        { $inc: { balance: -amount } },
        { session }
      );
      await db.collection('accounts').updateOne(
        { _id: toId },
        { $inc: { balance: amount } },
        { session }
      );
    });
  } finally {
    await session.endSession(); // Release session resources
  }
}

Pool Configuration: Sizing and Timeout Strategies

Luckily we can optimize pool performance through careful configuration. When I was first configuring pools, I made the classic mistake of thinking "more connections = better performance." That's rarely true.

Here's what I learned about sizing:

The formula I use for maximum pool size is: (Core Count * 2) + Effective Spindle Count. For a typical Node.js server with 4 cores and SSD storage, that's roughly 10-20 connections. In other words, your database can only efficiently handle a certain number of concurrent operations regardless of how many connections you throw at it.

Setting max too high causes:

  • Increased memory usage on both client and database
  • Connection thrashing and context switching
  • Database performance degradation
  • Slower query execution times

Setting max too low causes:

  • Request queuing and timeouts
  • Poor throughput under load
  • Wasted server capacity

The min setting determines how many idle connections to keep warm. I typically set this to 25-50% of max. This ensures quick response times for the first requests after idle periods without wasting resources.

Timeout configuration is equally important:

  • connectionTimeoutMillis: How long to wait for an available connection. I use 2-5 seconds. If your pool is exhausted for longer than this, you have bigger problems.
  • idleTimeoutMillis: How long before closing idle connections. I use 30 seconds for most applications. This prevents accumulating stale connections while keeping enough warm for traffic spikes.

Pool configuration comparison chart

Connection Pool Patterns: When to Check Out vs Query Direct

I came across this confusion constantly: when should you explicitly check out a connection versus using pool.query directly?

Use pool.query (automatic checkout) when:

  • Running a single query
  • No transaction required
  • Simple read operations
  • Default behavior for 80% of use cases

Explicitly check out a connection when:

  • Running multiple related queries
  • Using transactions
  • Need to maintain session state
  • Streaming large result sets

Here's a practical example showing both patterns:

// Pattern 1: Direct pool query (most common)
async function getRecentOrders(userId: number) {
  // Pool automatically checks out/in connection
  const result = await pool.query(
    'SELECT * FROM orders WHERE user_id = $1 ORDER BY created_at DESC LIMIT 10',
    [userId]
  );
  return result.rows;
}
 
// Pattern 2: Explicit connection checkout (transactions)
async function createOrderWithItems(order: Order, items: OrderItem[]) {
  const client = await pool.connect();
  try {
    await client.query('BEGIN');
    
    const orderResult = await client.query(
      'INSERT INTO orders (user_id, total) VALUES ($1, $2) RETURNING id',
      [order.userId, order.total]
    );
    
    const orderId = orderResult.rows[0].id;
    
    for (const item of items) {
      await client.query(
        'INSERT INTO order_items (order_id, product_id, quantity) VALUES ($1, $2, $3)',
        [orderId, item.productId, item.quantity]
      );
    }
    
    await client.query('COMMIT');
    return orderId;
  } catch (error) {
    await client.query('ROLLBACK');
    throw error;
  } finally {
    client.release(); // Critical!
  }
}

Error Handling and Automatic Reconnection

Database connections fail. Networks drop. Servers restart. Your pool needs to handle these gracefully.

The pg library provides excellent error handling hooks:

import { Pool } from 'pg';
 
const pool = new Pool({
  host: 'localhost',
  database: 'myapp',
  max: 20,
});
 
// Handle errors on idle clients
pool.on('error', (err, client) => {
  console.error('Unexpected error on idle client', err);
  // Pool will automatically remove the broken connection
  // and create a new one as needed
});
 
// Handle connection creation errors
pool.on('connect', (client) => {
  console.log('New client connected to the pool');
});
 
// Graceful shutdown
async function shutdown() {
  console.log('Draining pool...');
  await pool.end();
  console.log('Pool drained');
  process.exit(0);
}
 
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);

The beauty here is that modern pool implementations handle reconnection automatically. When a connection fails, the pool removes it and creates a fresh one. Your application code doesn't need special retry logic in most cases.

Monitoring Pool Health: Metrics and Best Practices

You can't optimize what you don't measure. I always add pool monitoring to my applications:

// Export pool metrics for monitoring
function getPoolMetrics() {
  return {
    total: pool.totalCount,        // Total connections in pool
    idle: pool.idleCount,          // Idle connections available
    waiting: pool.waitingCount,    // Requests waiting for connection
  };
}
 
// Log metrics periodically
setInterval(() => {
  const metrics = getPoolMetrics();
  console.log('Pool metrics:', metrics);
  
  // Alert if pool is consistently exhausted
  if (metrics.waiting > 5) {
    console.warn('Pool under pressure - consider increasing max size');
  }
  
  // Alert if too many idle connections
  if (metrics.idle > metrics.total * 0.8) {
    console.warn('Many idle connections - consider decreasing min size');
  }
}, 60000);

Key metrics to monitor:

  • Wait time: If requests are waiting more than a few hundred milliseconds, increase pool size
  • Idle ratio: If idle connections are consistently high, decrease min
  • Connection errors: Spike indicates network or database issues
  • Query duration: Sudden increases might indicate connection or query issues

Common Pitfalls and How to Avoid Connection Leaks

Let me share the most common mistakes I've seen (and made myself):

Forgetting to release connections: This is the number one issue. Always use try/finally blocks or stick with pool.query.

Setting pool size too high: More isn't better. Start with 10-20 and adjust based on metrics.

Not handling errors: Failed queries can leave connections in inconsistent states. Always catch errors and ensure cleanup.

Mixing pool and client instances: Don't create multiple pools for the same database. Share one pool across your application.

Not cleaning up on shutdown: Always drain pools gracefully before process exit to avoid leaving hanging connections.

Creating pools inside request handlers: This is surprisingly common. Create your pool once at application startup, not per-request.

When I finally decided to implement these practices consistently, my application's database performance improved dramatically. Connection-related errors dropped to nearly zero, and I could handle 10x the traffic with the same database instance.

And that concludes the end of this post! I hope you found this valuable and look out for more in the future!