#The official driver
AddedThe mongodb package is MongoDB's official Node.js driver, and everything else (Mongoose, Prisma's MongoDB support, most frameworks) is built on it. Its API mirrors mongosh closely: db.collection("products").find(filter) looks almost the same, with two differences that trip people up. Every call is asynchronous and returns a promise, and find() returns a cursor, not an array; nothing is fetched until you iterate it or call toArray().
The other thing to understand is that a MongoClient is not a connection, it's a connection pool plus a monitor. It discovers the deployment (standalone, replica set members, mongos routers), keeps up to maxPoolSize connections (100 by default) per server, routes each operation to the right member, and follows the primary through failovers. The mental model: the client is a taxi company, not a taxi. You create one per process at startup and every request borrows a car from its fleet.
The examples in this chapter run against the sandbox's single-member replica set on 127.0.0.1:27018, with directConnection=true because the member advertises itself as localhost:27017 inside its container.
import { MongoClient } from "mongodb";
const client = new MongoClient("mongodb://127.0.0.1:27018/?directConnection=true");
async function main() {
await client.connect();
const products = client.db("ch22").collection("products");
await products.deleteMany({});
const ins = await products.insertMany([
{ _id: 1, name: "ThinkBook 14", category: "Laptops", price: 899, stock: 12 },
{ _id: 2, name: "MacBook Air", category: "Laptops", price: 1299, stock: 5 },
{ _id: 3, name: "Pixel 10", category: "Phones", price: 799, stock: 20 },
{ _id: 4, name: "iPhone 17", category: "Phones", price: 1099, stock: 0 },
]);
console.log("inserted:", ins.insertedCount);
const inStock = await products
.find({ stock: { $gt: 0 } }, { projection: { _id: 0, name: 1, price: 1 } })
.sort({ price: -1 })
.toArray();
console.log(inStock);
const upd = await products.updateOne({ _id: 3 }, { $inc: { stock: -1 } });
console.log({ matched: upd.matchedCount, modified: upd.modifiedCount });
console.log(await products.findOne({ _id: 99 }));
}
main()
.catch(console.error)
.finally(() => client.close());inserted: 4
[
{ name: 'MacBook Air', price: 1299 },
{ name: 'ThinkBook 14', price: 899 },
{ name: 'Pixel 10', price: 799 }
]
{ matched: 1, modified: 1 }
nullnew MongoClient(uri)only stores the settings.await client.connect()contacts the server, runs the handshake and starts monitoring. (Since driver 4.7 the first operation connects automatically too, but an explicitconnect()makes startup failures surface at startup.)client.db("ch22").collection("products")creates lightweight handles; no network traffic.insertManysends the four documents in one command and resolves to{ insertedCount: 4, insertedIds }.find(filter, { projection })builds a cursor..sort({ price: -1 })adds to it. Only.toArray()sends the query and collects every batch into an array: the three products withstock > 0, most expensive first, with justnameandprice. In the driver the projection goes inside an options object, unlike mongosh's second argument.updateOneresolves to a result object;matchedCountandmodifiedCountare how you tell "not found" from "found but already in that state".findOnefor a missing_idresolves tonull, not an error. Always handle thenull.client.close()infinallycloses the pool so the process can exit. In a web server you'd close it only on shutdown.
import { MongoClient, MongoServerError, ObjectId } from "mongodb";
const client = new MongoClient("mongodb://127.0.0.1:27018/?directConnection=true");
const products = client.db("ch22").collection("products");
try {
await products.insertOne({ _id: 1, name: "Duplicate" });
} catch (err) {
if (err instanceof MongoServerError && err.code === 11000) {
console.log("duplicate:", err.keyValue, "-", err.errmsg);
} else throw err;
}
// No _id given: the driver generates an ObjectId before sending
const { insertedId } = await products.insertOne({ name: "USB-C Charger", price: 29 });
console.log(insertedId instanceof ObjectId, insertedId.getTimestamp().toISOString().slice(0, 10));
// A string is not an ObjectId: this finds nothing
console.log(await products.findOne({ _id: insertedId.toHexString() }));
console.log((await products.findOne({ _id: new ObjectId(insertedId.toHexString()) })).name);
await client.close();duplicate: { _id: 1 } - E11000 duplicate key error collection: ch22.products index: _id_ dup key: { _id: 1 }
true 2026-10-16
null
USB-C Charger- Inserting
_id: 1again throws aMongoServerErrorwithcode: 11000.err.keyValuetells you which value collided. Checkcode, never parse the message. - Without an
_id, the driver (not the server) creates anObjectIdand adds it to the document before sending, which is whyinsertedIdis known even before the round trip. Its first 4 bytes are a timestamp:getTimestamp()gives today's date. - The classic bug: an id arrives from a URL as a string,
"6ad2…", andfindOne({ _id: thatString })returnsnull, because a string never equals anObjectIdin BSON. - Converting with
new ObjectId(hex)finds the document. Validate first withObjectId.isValid(hex), since the constructor throws on malformed input.
The pool only helps if it's shared. One client with maxPoolSize: 10 serves 200 parallel queries; then 50 "requests" each create their own client:
import { MongoClient } from "mongodb";
const uri = "mongodb://127.0.0.1:27018/?directConnection=true";
const serverConnections = async () => {
const c = await MongoClient.connect(uri);
const n = (await c.db("admin").command({ serverStatus: 1 })).connections.current;
await c.close();
return n - 1; // minus this checking connection
};
// Good: one client for the whole app, shared by every request
const shared = new MongoClient(uri, { maxPoolSize: 10 });
let created = 0;
shared.on("connectionCreated", () => created++);
const products = shared.db("ch22").collection("products");
await Promise.all(Array.from({ length: 200 }, () => products.find({ price: { $gt: 100 } }).toArray()));
console.log(`one client, 200 parallel queries: ${created} pooled connections, server sees ${await serverConnections()}`);
await shared.close();
// Bad: a new client per request
const perRequest = await Promise.all(Array.from({ length: 50 }, async () => {
const c = await MongoClient.connect(uri);
await c.db("ch22").collection("products").findOne({});
return c;
}));
console.log(`a client per request, 50 requests: server sees ${await serverConnections()} connections`);
await Promise.all(perRequest.map((c) => c.close()));one client, 200 parallel queries: 10 pooled connections, server sees 13
a client per request, 50 requests: server sees 102 connections- 200 queries start at once on the shared client. The pool opens connections up to
maxPoolSize(theconnectionCreatedevent fired 10 times), and the other 190 queries wait in the pool's queue for a free connection rather than opening new ones. - The server counts 13: the 10 pooled connections plus the client's monitoring connections (and anything else connected to this shared sandbox at that moment).
- The anti-pattern: each request creates a
MongoClient. Every client has its own pool and its own monitoring connections, so 50 requests produced about 100 server connections, each with a full handshake (and in production a TLS handshake and SCRAM authentication) before doing any work. - Under load that pattern exhausts the server's connection limit and makes every request slower. Create the client once at startup, export it, and reuse it. In serverless functions, create it outside the handler so warm invocations reuse it.
import { MongoClient } from "mongodb";
const client = new MongoClient("mongodb://127.0.0.1:27018/?directConnection=true");
let getMores = 0;
const monitored = new MongoClient("mongodb://127.0.0.1:27018/?directConnection=true", { monitorCommands: true });
monitored.on("commandStarted", (e) => { if (e.commandName === "getMore") getMores++; });
const events = client.db("ch22").collection("events");
await events.drop().catch(() => {});
await events.insertMany(Array.from({ length: 1000 }, (_, i) => ({ n: i, type: i % 3 ? "view" : "click" })));
// Stream a large result instead of loading it all with toArray()
let clicks = 0;
for await (const doc of monitored.db("ch22").collection("events").find({}, { batchSize: 100 })) {
if (doc.type === "click") clicks++;
}
console.log({ clicks, getMores });
// Many different writes in one round trip
const res = await events.bulkWrite([
{ insertOne: { document: { n: 1000, type: "click" } } },
{ updateMany: { filter: { type: "view", n: { $lt: 10 } }, update: { $set: { type: "seen" } } } },
{ deleteOne: { filter: { n: 0 } } },
{ replaceOne: { filter: { n: 1 }, replacement: { n: 1, type: "replaced" } } },
], { ordered: true });
console.log({ inserted: res.insertedCount, matched: res.matchedCount, modified: res.modifiedCount, deleted: res.deletedCount });
await client.close(); await monitored.close();{ clicks: 334, getMores: 10 }
{ inserted: 1, matched: 7, modified: 7, deleted: 1 }for await … of cursorpulls documents batch by batch. Thefindcommand returns the first 100; eachgetMorefetches the next 100. That was 10getMorecommands for 1,000 documents (the last one finds the cursor exhausted), with only one batch in memory at a time.- Counting 334 clicks (every third
n,0to999) this way works for a million documents too, wheretoArray()would hold them all in memory at once. bulkWritesends four different operations in one round trip. TheupdateManymatched 6 views withn < 10(1, 2, 4, 5, 7, 8), and thereplaceOnematched 1: 7 matched and modified in total.ordered: true(the default) stops at the first error;ordered: falsecarries on and lets the server run the operations in any order, which is faster for big independent batches.
mongodb+srv://cluster0.example.mongodb.net/(Atlas) looks up the hosts in DNS SRV records and turns on TLS.replicaSet=rs0: the driver discovers all members from any one of them.directConnection=truetalks to exactly one host and skips discovery, as in this sandbox.maxPoolSize(default 100),minPoolSize,maxIdleTimeMS,waitQueueTimeoutMS: pool behaviour.retryWrites=trueandretryReads=trueare both on by default in current drivers: one automatic retry after a network error or failover.w=majority,readPreference=secondaryPreferred,readConcernLevel=majority: defaults for the client (see Write concern and Read concern and read preference).timeoutMS: one overall time limit per operation, including retries and pool waits (client-side operation timeout in recent drivers).appName=checkout-service: shows up in the server's slow query log and$currentOp, so you can tell which service sent what.