One MongoClient per process, created at startup, shared by every route. A client owns a connection pool per server it knows about, and the pool is what makes a request cheap: the TCP, TLS and hello handshakes happen once, then every query borrows an idle socket and returns it. A client created in a route handler pays all of that per request and leaks sockets until the server refuses more.

maxPoolSize (default 100) is a limit per server and a concurrency limit: with maxPoolSize=3, nine simultaneous 200-millisecond queries took 674 ms here — three rounds of three — the rest waiting in a queue. Measure your own with the events the client emits — connectionCreated, connectionClosed, connectionCheckedOut and connectionCheckOutFailed among them.
import { MongoClient } from "mongodb";
const client = new MongoClient(process.env.MONGO_URI, {
appName: "bookshelf-api", maxPoolSize: 20, minPoolSize: 2, serverSelectionTimeoutMS: 5000,
});
let poolSize = 0;
client.on("connectionCreated", () => { poolSize += 1; });
client.on("connectionClosed", () => { poolSize -= 1; });
export const books = client.db("bookshelf").collection("books");
export const metrics = () => ({ poolSize });
export const closeDb = () => client.close();
export async function connectDb() {
await client.connect(); // one handshake for the whole process
await books.createIndex({ isbn: 1 }, { unique: true });
console.log("mongo connected, pool has", poolSize, "socket(s)");
}Routes import the collection and forget about connections. Shutdown is the other half: stop the listener, let in-flight handlers finish, then close the client.
import express from "express";
import { books, metrics, connectDb, closeDb } from "./db.mjs";
const app = express();
app.get("/books", async (req, res) => res.json({ pool: metrics().poolSize,
books: await books.find({}, { projection: { _id: 0 } }).limit(20).toArray() }));
await connectDb();
const server = app.listen(3111, () => console.log("api listening on 3111"));
for (const sig of ["SIGTERM", "SIGINT"]) process.on(sig, () => {
console.log(`${sig} received, draining`);
server.close(async () => { // in-flight requests finish, then the pool
await closeDb();
console.log("pool drained to", metrics().poolSize, "sockets, bye"); process.exit(0);
});
});mongo connected, pool has 2 socket(s)
api listening on 3111
GET /books -> 200 {"pool":2,"books":[{"isbn":"978-0-441-01359-3","title":"Dune","year":1965}]}
SIGTERM received, draining
pool drained to 0 sockets, byeminPoolSize: 2 is why two sockets exist before the first request: the pool pre-warms in the background, so the first visitor pays no handshake. Size maxPoolSize so that instances x maxPoolSize stays under the server's connection limit — an Atlas 1,815 M0 allows 500. Windows cannot deliver SIGTERM, so the run above raised it in-process; in a container the orchestrator sends the real signal.