Skip to content
ExponaBLOG
MONGODB, NOSQL & DATA MODELING · DAY 054 / 100

Connection pool sizing in MongoDB, NoSQL & Data Modeling

Too many connections hurt performance.

Expona Admin7 min read

Connection pool sizing in MongoDB, NoSQL & data modeling is one of those topics that doesn't get enough attention until your application suddenly starts hanging or you find yourself with dozens of idle database connections draining resources. We've seen this pattern play out across multiple production systems: teams set up MongoDB and never think about connection pools again, then wonder why latency spikes under load or why they're hitting connection limits on their database tier.

Understanding the connection pool problem

Before we dig into sizing strategy, let's establish what connection pooling actually does. A MongoDB driver maintains a pool of TCP connections to your server. Rather than creating a new connection for every database operation—which would be prohibitively expensive—the driver reuses connections from this pool. Each connection is expensive to create; it involves TCP handshake, TLS negotiation, and MongoDB authentication, potentially adding hundreds of milliseconds per operation.

The tension in connection pool sizing comes from two competing forces. Too few connections and your application threads block waiting for a free connection to become available, hurting throughput and response times. Too many connections and you waste memory on your application side, exhaust the server's file descriptor limits, and create unnecessary overhead in the MongoDB server's connection management layer.

One client per application process

The foundational principle of MongoDB connection pooling is straightforward: create exactly one MongoClient instance per application process and reuse it for the entire lifetime of the application. This is not optional guidance—it's a requirement for production systems.

We often see this pattern in legacy applications where developers instantiate a new client for each request or controller action. This completely defeats the purpose of pooling. Each instantiation creates a new set of connections, and because connection teardown is asynchronous, you end up with connection leaks. Within hours, you're exhausting system resources and hitting connection limits.

Here's how to structure this correctly in a Node.js/NestJS application:

javascript
// mongodb.module.ts
import { Module } from '@nestjs/common';
import { MongoClient, Db } from 'mongodb';

let client: MongoClient;
let db: Db;

@Module({
  providers: [
    {
      provide: 'MONGO_CLIENT',
      useFactory: async () => {
        if (!client) {
          client = new MongoClient(process.env.MONGODB_URI, {
            maxPoolSize: 50,
            minPoolSize: 10,
          });
          await client.connect();
          db = client.db('production_db');
        }
        return client;
      },
    },
    {
      provide: 'MONGO_DB',
      inject: ['MONGO_CLIENT'],
      useFactory: async (mongoClient: MongoClient) => {
        return mongoClient.db('production_db');
      },
    },
  ],
  exports: ['MONGO_CLIENT', 'MONGO_DB'],
})
export class MongoDBModule {}

This singleton pattern ensures that across your entire application—whether you have 10 controllers or 100—there's only one MongoClient managing connections. When you need to query a collection, you inject the database instance and use it directly.

Set maxPoolSize based on load

Sizing maxPoolSize requires understanding your application's concurrency profile. The default is typically 100, which works for many applications but is often wrong for both directions.

Start with this mental model: maxPoolSize should be large enough that your application threads rarely block waiting for a connection, but not so large that you're maintaining idle connections. A reasonable starting formula is:

javascript
maxPoolSize = (expectedConcurrentRequests * avgDurationOfDbOperationMs) / 1000 + buffer

Let's work through a concrete example. Suppose you have a NestJS API handling 100 concurrent requests, each database operation takes roughly 10ms on average, and you want a 20% safety buffer:

javascript
// Rough calculation
// 100 concurrent requests * 10ms / 1000ms = 1 connection needed
// Add 20% buffer: 1 * 1.2 = 1.2
// Round up and add overhead: aim for 15–20 connections

const mongoClient = new MongoClient(mongoUri, {
  maxPoolSize: 20,
  minPoolSize: 5,
  maxPoolIdleTimeMS: 45000,
});

In practice, we've found that for typical CRUD-heavy services, maxPoolSize in the 10–50 range covers 90% of use cases. For high-throughput analytics or real-time trading systems, you might push toward 100–200.

minPoolSize deserves attention too. This is the number of connections the driver maintains eagerly even when idle. Set it to a value that covers your baseline load—the minimum concurrent queries you expect even during quiet periods. This avoids the "cold start" problem where the first few requests after idle time wait for connection establishment.

Watch total connections across instances

Connection pool sizing becomes much trickier when you scale horizontally. MongoDB has a per-server connection limit (default 1,000,000 but often configured lower), and you need to account for connections from all your application instances.

Imagine you've sized maxPoolSize for a single instance at 50 connections. If you then scale to 10 application instances, you're suddenly trying to maintain 500 connections to MongoDB. If you scale to 20 instances, that's 1,000 connections just from your app servers. The database server struggles with connection management overhead, memory usage spikes, and latency increases.

This is where you need to work backwards from your MongoDB infrastructure:

  1. Know your MongoDB connection limit (check serverStatus().connections or your cloud provider's documentation)
  2. Reserve connections for admin tasks, monitoring, and other services (typically 50–100)
  3. Divide the remaining budget by your number of application instances
  4. Set maxPoolSize to that per-instance allocation

For example, if MongoDB allows 5,000 connections and you're running 20 application instances:

javascript
// Available budget: 5,000 - 100 (reserved) = 4,900
// Per instance: 4,900 / 20 = 245
// But be conservative—use 100 per instance
const mongoClient = new MongoClient(mongoUri, {
  maxPoolSize: 100,
  minPoolSize: 10,
});

Monitor this actively. Set up dashboards showing connections per instance and total connections across all instances. If you're consistently near the limit, you've either sized wrongly or your load is genuinely higher than expected.

Serverless functions need connection reuse

Serverless platforms (AWS Lambda, Google Cloud Functions) create unique challenges because each invocation runs in an isolated container. The standard approach of a singleton client still works, but you need to be intentional about connection reuse across invocations.

When a Lambda function runs, it persists state (including database connections) between invocations if the container is reused. This is actually a feature for connection pooling: the first invocation establishes connections, and subsequent invocations reuse them. However, if containers are recycled or if you have many concurrent invocations, you can still exhaust resources.

Here's the pattern for Lambda with TypeScript:

javascript
// handler.ts
import { MongoClient, Db } from 'mongodb';

let cachedClient: MongoClient;
let cachedDb: Db;

async function connectToDatabase() {
  if (cachedClient && cachedClient.topology?.isConnected()) {
    return cachedDb;
  }

  const client = new MongoClient(process.env.MONGODB_URI, {
    maxPoolSize: 5,
    minPoolSize: 1,
  });
  
  await client.connect();
  cachedClient = client;
  cachedDb = client.db('production_db');
  
  return cachedDb;
}

export const handler = async (event) => {
  const db = await connectToDatabase();
  const result = await db.collection('orders').findOne({ _id: event.orderId });
  return { statusCode: 200, body: JSON.stringify(result) };
};

For serverless, maxPoolSize should be much smaller—typically 5–10. You're not handling dozens of concurrent requests in a single container; you're handling one invocation at a time, and you share the container with other invocations sequentially.

Also be aware of connection timeout settings. Long-idle serverless containers may see their connections closed by the database after a period of inactivity. Use maxPoolIdleTimeMS to balance this: if you set it too low (e.g., 5 seconds), the driver closes idle connections and you lose the pooling benefit. Too high (e.g., 5 minutes) and MongoDB may close the connection server-side, causing errors on next use.

Common mistakes

  • Creating multiple MongoClient instances: We see this in applications where different modules initialize their own clients. Use dependency injection or a shared singleton module to prevent this.
  • Setting maxPoolSize based on documentation examples: The defaults in MongoDB documentation work for basic tutorials, not production traffic. Measure your actual concurrency and size accordingly.
  • Ignoring minPoolSize: When minPoolSize is too low, the first request after idle time blocks while the driver establishes a new connection. Set it to cover your baseline load.
  • Not accounting for other connections: Monitoring tools, replica set heartbeats, and other services also consume connections. Always reserve headroom.
  • Scaling instances without revisiting pool size: If you double your application instances, your total connection count doubles too. Review and adjust maxPoolSize when scaling.
  • Using aggressive pool idle timeouts in production: Timeouts that are too strict force reconnection overhead and negate pooling benefits. Typical values are 30–60 seconds.

Key takeaways

  • Create one MongoClient per application process and reuse it for the entire application lifetime.
  • Set maxPoolSize based on your concurrent request load and database operation duration, typically 10–50 for standard services.
  • Calculate maxPoolSize per instance by dividing your MongoDB connection budget by the number of application instances.
  • Monitor total connections across all instances to avoid hitting server-side limits.
  • For serverless, use smaller pool sizes (5–10) and be cautious with idle timeout settings.
  • Don't rely on defaults; measure, test under realistic load, and adjust iteratively.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles