Production Connections: Pools, Timeouts & Retries
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:
| Option | Default | Meaning |
|---|---|---|
maxPoolSize | 100 | Most connections per server, including those in use |
minPoolSize | 0 | Connections kept open even when idle |
maxIdleTimeMS | 0 (no limit) | Close a connection after it has been idle this long |
maxConnecting | 2 | Connections being opened at the same time |
waitQueueTimeoutMS | 0 (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:
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.maxTimeMSon 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:
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();
}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 asinsertOne(),updateOne(),deleteOne()orfindOneAndUpdate()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()anddeleteMany()are not retryable. - Retryable reads (
retryReads, on by default): reads such asfind()andaggregate()are retried once after a similar error. - Transactions:
withTransaction()retries the whole transaction on aTransientTransactionErrorand 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:
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:
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
globalThisin 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
maxPoolSizesmall (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:
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
MongoClientper request. Connections pile up and every request pays the handshake cost. - Huge pools across many processes. Multiply
maxPoolSizeby 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
MongoClientper process manages a pool;maxPoolSize(default 100) is per process and per server. - Set
appNameand a shorterserverSelectionTimeoutMSso 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
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?
maxPoolSize is per process, so 8 processes × 100 = 800.
2. Which write is NOT retried automatically by retryable writes?
Retryable writes cover single-document operations; multi-document updates and deletes are not retried.
3. What error do you get when the driver cannot reach any suitable server within serverSelectionTimeoutMS?
Server selection errors mean no usable server was found in time, often because it is down or unreachable.
4. Why use a causally consistent session when reading from secondaries?
Causal consistency orders operations within a session, so a user sees what they just wrote.
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
