Connection Pooling in Node.js

Every time your Node.js app talks to MongoDB, it does so over a TCP connection. Opening and authenticating a new connection for every query is slow and wasteful, so the MongoDB Node.js driver (and Mongoose, which wraps it) maintains a connection pool: a set of already-open, already-authenticated connections that operations borrow and return. Understanding how this pool works — and how to avoid recreating it by accident — is one of the most impactful things you can learn for a production Node.js + MongoDB app.

Overview / How it works

When you call new MongoClient(uri, options) and then client.connect(), the driver does not open one connection. It creates a connection pool per server it talks to — one pool for a standalone server, or one pool per member it monitors in a replica set (the driver only actively pools connections to the primary and to secondaries you read from, plus a small number of monitoring connections used for heartbeats). Each pool starts empty (or with minPoolSize connections warmed up in the background) and grows on demand, up to maxPoolSize connections, as concurrent operations need them.

When your code calls something like orders.find(...), the driver checks out a connection from the pool, sends the query on it, waits for the response, and checks the connection back in to the pool — it does not close it. The next operation reuses that same TCP connection instead of paying the cost of a new TCP handshake, TLS negotiation, and authentication handshake every single time. This is exactly why the driver documentation and every MongoDB engineer will tell you: create one MongoClient per application process, and reuse it for the lifetime of that process. The client itself is thread-safe (in the async sense — safe to share across concurrent requests) precisely because the pool is designed to be shared and checked in/out concurrently.

If every operation currently has a connection checked out and the pool is already at maxPoolSize, new operations queue up and wait for one to be returned, up to waitQueueTimeoutMS before erroring out. Idle connections beyond minPoolSize that go unused for longer than maxIdleTimeMS are closed to free up resources on both the client and the server. This mirrors connection pooling in SQL drivers (like a JDBC or pg pool) — if you have used one, the mental model transfers directly.

It is worth being explicit about scale: if you run 10 instances of your Node app, each with maxPoolSize: 100, you can open up to 1,000 connections to that MongoDB deployment. MongoDB Atlas clusters (and self-hosted mongod) have a hard connection ceiling depending on tier/hardware, so pool size is a cluster-wide capacity decision, not just a per-app tuning knob.

Syntax

Pool behavior is configured through options passed to MongoClient (or mongoose.connect, which accepts the same underlying driver options):

Option Default Meaning
maxPoolSize 100 Maximum number of connections the pool will open per server.
minPoolSize 0 Number of connections the driver tries to keep open and ready, even when idle.
maxConnecting 2 How many connections the pool is allowed to establish concurrently while growing.
maxIdleTimeMS 0 (never) Milliseconds a connection may sit idle before the pool closes it.
waitQueueTimeoutMS 0 (never) Milliseconds an operation waits for a free connection before throwing a timeout error.
socketTimeoutMS 0 (never) Milliseconds a socket may be inactive during an operation before it is considered dead.
serverSelectionTimeoutMS 30000 How long the driver waits to find a suitable server (e.g. a primary) before failing.

Examples

Example 1: Creating a client with explicit pool sizing

import { MongoClient } from "mongodb";

const uri = "mongodb://<user>:<password>@<cluster-url>/mydb";

const client = new MongoClient(uri, {
  maxPoolSize: 20,
  minPoolSize: 5,
  maxIdleTimeMS: 30000,
  waitQueueTimeoutMS: 5000,
});

await client.connect();

const db = client.db("shop");
const orders = db.collection("orders");

const result = await orders.insertOne({
  customer: "Ada Lovelace",
  total: 129.99,
  status: "pending",
});

console.log(result.insertedId);

Output:

new ObjectId("64f1a2b3c4d5e6f7a8b9c0d1")

The pool for this connection allows up to 20 concurrent connections, keeps 5 warm at all times, and gives up waiting for a free connection after 5 seconds under heavy load. Nothing about this single insert looks different from a driver with default settings — the pool only becomes visible under concurrency.

Example 2: Sharing one client across an Express app

The pattern that actually matters in production is creating the client once, in a module, and importing it everywhere else:

// db.js - create the client ONCE and export it
import { MongoClient } from "mongodb";

const client = new MongoClient(process.env.MONGO_URI, {
  maxPoolSize: 50,
});

let dbConnection;

export async function connectToDb() {
  if (!dbConnection) {
    await client.connect();
    dbConnection = client.db("shop");
    console.log("Connected, pool ready");
  }
  return dbConnection;
}

export async function closeDb() {
  await client.close();
}
// orders-route.js
import { connectToDb } from "./db.js";

export async function handleGetOrders(req, res) {
  const db = await connectToDb();
  const orders = await db
    .collection("orders")
    .find({ status: "shipped" })
    .limit(20)
    .toArray();
  res.json(orders);
}

Output:

Connected, pool ready
[ { _id: ObjectId("..."), customer: "Grace Hopper", status: "shipped", total: 89.5 }, ... ]

Every request that hits handleGetOrders calls connectToDb(), but the if (!dbConnection) guard means client.connect() only runs once, on the first call. Every subsequent request reuses the exact same pool of connections, which is the entire point — the pool amortizes connection setup cost across the whole lifetime of the process, not per request.

Example 3: Watching the pool with connection events

client.on("connectionPoolCreated", (event) => {
  console.log("Pool created for", event.address);
});

client.on("connectionCreated", (event) => {
  console.log("New connection", event.connectionId);
});

client.on("connectionClosed", (event) => {
  console.log("Connection closed", event.connectionId, event.reason);
});

client.on("connectionCheckOutFailed", (event) => {
  console.warn("Could not check out a connection:", event.reason);
});

Output:

Pool created for cluster0-shard-00-01.mongodb.net:27017
New connection 1
New connection 2
Connection closed 1 idle

These events fire on the MongoClient instance itself and are invaluable for diagnosing pool exhaustion in production: a stream of connectionCheckOutFailed events under load is a direct signal that maxPoolSize is too small (or that connections are being held too long) for your traffic.

Example 4: The same idea in Mongoose

import mongoose from "mongoose";

await mongoose.connect("mongodb://<user>:<password>@<cluster-url>/mydb", {
  maxPoolSize: 25,
  minPoolSize: 5,
  socketTimeoutMS: 45000,
});

const orderSchema = new mongoose.Schema({
  customer: String,
  total: Number,
  status: String,
});

const Order = mongoose.model("Order", orderSchema);

const order = await Order.create({
  customer: "Grace Hopper",
  total: 89.5,
  status: "pending",
});

console.log(order._id);

Output:

new ObjectId("64f1a2c9d1e2f3a4b5c6d7e8")

Mongoose’s mongoose.connect() creates and owns a single underlying MongoClient (and therefore a single pool) internally. As with the raw driver, you call mongoose.connect() once at startup, not per request or per model call — every Model.find()/Model.create() call afterward automatically shares that one pool.

How it works step by step

  1. Startup: client.connect() resolves DNS (for SRV connection strings), opens the initial monitoring connections, and if minPoolSize is set, begins opening that many pooled connections in the background.
  2. Checkout: when an operation like find() or insertOne() runs, the driver asks the pool for a connection. If an idle one exists, it’s handed out immediately.
  3. Growth: if no idle connection exists and the pool is below maxPoolSize, the driver opens a new one (up to maxConnecting at a time, to avoid a thundering herd of simultaneous TCP/TLS handshakes).
  4. Queueing: if the pool is already at maxPoolSize and all connections are checked out, the operation waits in a queue until one is returned or waitQueueTimeoutMS elapses.
  5. Check-in: once the server responds, the connection goes back into the pool’s idle set — it is not closed.
  6. Idle reaping: a background sweep closes connections that have been idle longer than maxIdleTimeMS, down to a floor of minPoolSize.
  7. Shutdown: calling client.close() drains the pool, closing every connection cleanly so the server doesn’t have to time them out itself.

Common Mistakes

Mistake 1: Creating a new MongoClient on every request

This is the single most common Node.js + MongoDB performance bug. It looks harmless because each request “works,” but it re-runs the full connection handshake every time and can exhaust the server’s connection limit under real traffic.

// WRONG: opens a brand-new pool on every request
app.get("/orders", async (req, res) => {
  const client = new MongoClient(process.env.MONGO_URI);
  await client.connect();
  const orders = await client.db("shop").collection("orders").find().toArray();
  res.json(orders);
  await client.close();
});
// RIGHT: reuse the module-level client created once at startup
app.get("/orders", async (req, res) => {
  const db = await connectToDb();
  const orders = await db.collection("orders").find().toArray();
  res.json(orders);
});

The fix is always the same: create the MongoClient once, outside of any request handler, and pass it (or a resolved db object) into your route handlers.

Mistake 2: Never closing the pool on shutdown

Skipping cleanup means the process can hang on exit (open sockets keep the event loop alive) and the server is left holding connections it thinks are still active until it times them out on its own. Listen for shutdown signals and drain the pool explicitly:

process.on("SIGINT", async () => {
  await client.close();
  console.log("Connection pool drained, exiting");
  process.exit(0);
});

Mistake 3: Sizing maxPoolSize without thinking about total instances

Setting maxPoolSize: 500 on a single app instance seems generous, but multiply that by 10 horizontally-scaled instances (or 10 serverless functions each holding their own client) and you can hit your cluster’s connection ceiling and start rejecting connections for everyone. Size the pool per instance based on server connection limit ÷ number of app instances, with headroom for other services sharing the cluster.

Best Practices

  • Instantiate exactly one MongoClient (or call mongoose.connect() exactly once) per application process, and export/reuse it everywhere.
  • In serverless environments (AWS Lambda, etc.), create the client outside the handler function so it can be reused across warm invocations of the same container instead of reconnecting every call.
  • Set maxPoolSize based on your actual concurrency needs and the server’s connection budget, not an arbitrary large number.
  • Use minPoolSize to pre-warm connections for latency-sensitive services that see bursty traffic after idle periods.
  • Always register a graceful shutdown handler that calls client.close() so connections are released cleanly.
  • Monitor pool events (or your driver/Atlas metrics) in production to catch waitQueueTimeoutMS errors before users do — they mean your pool is undersized for current load.
  • Don’t call client.close() after every operation — only on actual application shutdown.

Practice Exercises

  • Create a db.js module that exports a singleton MongoClient with maxPoolSize: 10 and a connectToDb() function, then use it from two different mock “route handler” functions to confirm both share the same client instance.
  • Add listeners for the connectionPoolCreated, connectionCreated, and connectionClosed events on a client, connect, run a handful of concurrent find() calls with Promise.all, and observe how many connections actually get opened.
  • Write a graceful shutdown handler for a small Express app that listens for SIGTERM, stops accepting new HTTP requests, then calls client.close() before exiting — describe the order these three steps must happen in and why.

Summary

  • The MongoDB Node.js driver maintains a connection pool per server, reusing TCP connections across operations instead of opening a new one each time.
  • Create exactly one MongoClient (or call mongoose.connect() once) for the lifetime of your application process, and share it across all requests.
  • maxPoolSize, minPoolSize, maxIdleTimeMS, and waitQueueTimeoutMS are the main knobs for tuning pool behavior.
  • Creating a new client per request is the most common and most damaging mistake — it defeats pooling entirely and can exhaust server connection limits.
  • Always close the client on graceful shutdown, and monitor pool events or driver metrics to catch undersized pools before they cause timeouts in production.