EthicsComputerCourses
Join training +91 90532 10052

Production Connections: Pools, Timeouts & Retries

MongoDB · Lesson 31 of 40 Advanced 9 min read Updated

Code that works on your laptop can still fall over in production. The database restarts for an upgrade, a replica set elects a new primary, the network drops for two seconds, or a traffic spike opens hundreds of connections at once. How your application behaves in those moments depends mostly on how you configure and use the driver's MongoClient.

In this lesson you will tune the connection pool, choose timeouts that fail fast instead of hanging, understand what the driver retries for you, classify errors, read your own writes from secondaries, and add health checks and a clean shutdown. It builds on The Node.js Driver, using driver version 7.

The connection pool

Each MongoClient keeps a pool of open connections to every server it talks to. An operation borrows a connection, uses it and returns it. Creating connections is slow (network handshake, TLS, authentication), so the pool reuses them. These options control it; the defaults are those of the Node.js driver:

OptionDefaultMeaning
maxPoolSize100Most connections per server, including those in use
minPoolSize0Connections kept open even when idle
maxIdleTimeMS0 (no limit)Close a connection after it has been idle this long
maxConnecting2Connections being opened at the same time
waitQueueTimeoutMS0 (no limit)How long an operation waits for a free connection

Pool limits are per process. If you run your API with four Node.js processes (for example in PM2 cluster mode) and the default pool size, the database may see up to 400 connections from one machine, plus those from workers and cron jobs. Atlas Free clusters allow 500 connections in total, and every server has some limit. Size the pool from real numbers: most APIs need far fewer than 100 connections per process, because each query takes milliseconds.

Here is a production version of db.js:

JavaScript
import { MongoClient } from 'mongodb';

const uri = process.env.MONGODB_URI;
if (!uri) throw new Error('MONGODB_URI is not set');

export const client = new MongoClient(uri, {
  appName: 'shop-api',            // shows up in server logs and Atlas, so you know who connected
  maxPoolSize: 20,
  minPoolSize: 2,
  maxIdleTimeMS: 60_000,
  serverSelectionTimeoutMS: 5000  // fail after 5 s instead of 30 s when no server is reachable
});
export const db = client.db('shop');

appName costs nothing and saves hours during an incident: the slow query log and $currentOp show which application sent each operation. All of these options can also go into the connection string, such as ...?appName=shop-api&maxPoolSize=20.

Timeouts: fail fast, do not hang

Without limits, a request to your API can wait a long time for the database, holding a user's browser and a server thread. The driver has several timeouts:

  • serverSelectionTimeoutMS (default 30,000): how long to wait for a suitable server, for example a primary during an election. For a web API, 5 to 10 seconds is usually better than 30.
  • connectTimeoutMS (default 30,000): how long one attempt to open a TCP connection may take.
  • socketTimeoutMS (default 0, meaning none): how long to wait for a reply on an open connection. Leave it at 0 or set it well above your slowest legitimate query.
  • maxTimeMS on an operation: a limit the server enforces, as shown in Monitoring, Profiler & Slow Queries.

Driver 7 also has a single timeoutMS option ("client-side operation timeout") that limits the total time of an operation, including retries. The driver documentation still marks it as experimental, so check its status before relying on it.

With the server stopped, this health check shows what fail-fast looks like:

JavaScript
import { client, db } from './db.js';

try {
  const res = await db.command({ ping: 1 });
  console.log('MongoDB is up:', res.ok);
} catch (err) {
  console.log(err.name, '-', err.message);
} finally {
  await client.close();
}
Output
MongoServerSelectionError - connect ECONNREFUSED 127.0.0.1:27017

The error arrives after about five seconds, the serverSelectionTimeoutMS from db.js. With the server running, the same script prints MongoDB is up: 1. A /health route in your API can run the same ping so that a load balancer stops sending traffic to an instance that cannot reach the database.

What the driver retries for you

Short failures are normal in a replica set: during an election there is no primary for a few seconds. Modern drivers handle the most common cases automatically:

  • Retryable writes (retryWrites, on by default): if a single-document write such as insertOne(), updateOne(), deleteOne() or findOneAndUpdate() fails with a network error or because the primary stepped down, the driver retries it once on the new primary. The server remembers the write by a transaction number, so a retry never applies it twice. updateMany() and deleteMany() are not retryable.
  • Retryable reads (retryReads, on by default): reads such as find() and aggregate() are retried once after a similar error.
  • Transactions: withTransaction() retries the whole transaction on a TransientTransactionError and retries the commit on an unknown commit result, as in Transactions.

Because of these, most code never sees a failover. Do not wrap every call in your own retry loop: you may retry writes that are not safe to repeat and multiply load during an outage.

Classifying errors

When an error does reach your code, decide what kind it is before responding:

JavaScript
import { MongoServerError, MongoServerSelectionError } from 'mongodb';

export function describeDbError(err) {
  if (err instanceof MongoServerSelectionError) return { status: 503, message: 'Database unavailable, please retry' };
  if (err instanceof MongoServerError && err.code === 11000) return { status: 409, message: 'Already exists' };
  if (err instanceof MongoServerError && err.code === 121) return { status: 400, message: 'Document failed validation' };
  if (err.hasErrorLabel?.('TransientTransactionError')) return { status: 503, message: 'Busy, please retry' };
  return { status: 500, message: 'Unexpected database error' };
}

Code 11000 is the duplicate key error and 121 is a schema validation failure from Schema Validation with $jsonSchema: these are the client's problem and retrying will not help. Server selection errors and transient labels are temporary, so a 503 response tells the client to try again later. Log the full error on the server, but never send raw database messages to users; they can reveal collection and field names.

Reading your own writes from secondaries

If you send reads to secondaries (readPreference=secondaryPreferred) to take load off the primary, a user may place an order and then not see it on the next page, because the secondary has not copied it yet. Causal consistency fixes this within a session: operations in a causally consistent session see the effects of earlier operations in the same session, even on another member, when you use majority read and write concerns:

JavaScript
const session = client.startSession({ causalConsistency: true });
try {
  const orders = db.collection('orders');
  await orders.insertOne(
    { _id: 1013, customerId: 8, status: 'pending', total: 649 },
    { session, writeConcern: { w: 'majority' } }
  );
  const mine = await orders
    .find({ customerId: 8 }, { session, readPreference: 'secondaryPreferred', readConcern: { level: 'majority' } })
    .toArray();
  console.log(mine.length);
} finally {
  await session.endSession();
}

On the fresh shop data this prints 1: the secondary waits until it has caught up to the insert before answering. Without the session, the read could return 0. Keep reads that must be fresh on the primary unless you have measured a real need for secondaries; reports and analytics are better candidates for secondary reads, ideally with maxStalenessSeconds set so that a badly lagging member is avoided.

One client, also in serverless and dev servers

The rule "create one client per process" has two traps:

  • Development servers with hot reload (Next.js, nodemon) re-run your modules on every save, creating a new client each time until connections run out. Store the client on globalThis in development: globalThis._mongo ??= new MongoClient(uri).
  • Serverless functions (AWS Lambda, Vercel) may start many instances at once. Create the client outside the handler so warm invocations reuse it, keep maxPoolSize small (often 1 to 10), and remember that every cold instance opens its own pool.

Shutting down cleanly

When your process is stopped by a deployment, finish in-flight requests and close the pool so the server is not left with dead connections:

JavaScript
const server = app.listen(3000);

process.on('SIGTERM', () => {
  server.close(async () => {     // stop accepting requests, wait for open ones
    await client.close();        // then close every pooled connection
    process.exit(0);
  });
});

Container platforms and PM2 send SIGTERM before killing a process, so this handler runs on every normal deployment.

Common mistakes

  • A new MongoClient per request. Connections pile up and every request pays the handshake cost.
  • Huge pools across many processes. Multiply maxPoolSize by your process count before going live.
  • The default 30-second server selection timeout in a web API. Users wait half a minute for an error; fail faster.
  • Retrying everything by hand. The driver already retries safe single writes and reads once.
  • Sending raw error messages to users. Map errors to friendly messages and log the details privately.

Key points

  • One MongoClient per process manages a pool; maxPoolSize (default 100) is per process and per server.
  • Set appName and a shorter serverSelectionTimeoutMS so problems are visible and fail fast.
  • Retryable writes and reads (on by default) retry single-document writes and reads once after failovers.
  • Classify errors: 11000 and 121 are client errors; server selection and transient errors are temporary.
  • Causally consistent sessions with majority concerns let you read your own writes from secondaries.
  • Cache the client in dev and serverless environments, and close it on SIGTERM.

Exercise

Your Express API runs as 6 processes on two servers, plus one worker process for change streams, all against an Atlas Free cluster. Choose maxPoolSize values that keep the total well below the 500-connection limit, write the MongoClient options for the API processes, and add a /health route that returns HTTP 200 when ping succeeds and 503 otherwise.

Show solution
JavaScript
export const client = new MongoClient(process.env.MONGODB_URI, {
  appName: 'shop-api',
  maxPoolSize: 25,
  serverSelectionTimeoutMS: 5000
});

app.get('/health', async (req, res) => {
  try {
    await client.db('admin').command({ ping: 1 });
    res.status(200).json({ db: 'up' });
  } catch {
    res.status(503).json({ db: 'down' });
  }
});

Six API processes with 25 connections each can open at most 150 pooled connections per server they talk to. Atlas clusters are three-member replica sets and the driver also keeps monitoring connections, so the real total is higher than 150; the worker with a small pool (say 5, for its change streams) still leaves plenty of headroom under 500. The health route turns a server selection error into a 503 within five seconds.

Quick Quiz

1. Your API runs 8 processes, each with the default maxPoolSize. How many pooled connections can they open to the primary at most?

2. Which write is NOT retried automatically by retryable writes?

3. What error do you get when the driver cannot reach any suitable server within serverSelectionTimeoutMS?

4. Why use a causally consistent session when reading from secondaries?

Learn MongoDB with a mentor

Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call