The Node.js Driver in Depth

Intermediate
14 min

The Node.js Driver in Depth

The official mongodb package is what every Node.js integration, including Mongoose, is built on. Knowing it well means understanding the connection pool you are sharing, what each operation returns, how BSON types cross the JavaScript boundary, and which errors you should expect. After this lesson you will be able to configure one client per process correctly, consume cursors efficiently, type collections with TypeScript, and handle driver errors deliberately.

One Client, One Pool

MongoClient owns a pool of connections and is designed to be created once and shared for the life of the process. Creating a client per request exhausts server connections within minutes.

javascript
// db.js import { MongoClient } from "mongodb"; export const client = new MongoClient(process.env.MONGODB_URI, { maxPoolSize: 20, // default 100; size for your concurrency serverSelectionTimeoutMS: 5000, // fail fast if the cluster is unreachable appName: "orders-api" // shows up in server logs and Atlas metrics }); export async function connectDb() { await client.connect(); // optional since v4.7 (lazy), but fails fast at startup await client.db("admin").command({ ping: 1 }); return client.db("shop"); }

Call client.close() on shutdown so in-flight operations finish and sockets are released.

What Operations Return

| Method | Resolves to | |---|---| | insertOne | { acknowledged, insertedId } | | insertMany | { insertedCount, insertedIds } | | updateOne / updateMany | { matchedCount, modifiedCount, upsertedId } | | deleteOne / deleteMany | { deletedCount } | | findOne | the document or null | | findOneAndUpdate / findOneAndDelete | the document or null (driver 6.x) | | find / aggregate | a cursor, not data |

Cursors are lazy. Chain options, then consume:

javascript
const cursor = products.find({ stock: { $gt: 0 } }).project({ name: 1, price: 1 }).sort({ price: 1 }).limit(50); const list = await cursor.toArray(); // small results for await (const doc of cursor) { /* ... */ } // streaming, constant memory cursor.stream().pipe(transform); // Node stream for pipelines

BSON Types at the JavaScript Boundary

javascript
import { ObjectId, Decimal128, Long } from "mongodb"; const id = new ObjectId("67c1f0e8a1b2c3d4e5f60718"); ObjectId.isValid(req.params.id) // validate before querying id.toHexString() // "67c1..." for JSON responses Decimal128.fromString("19.99") // exact money Long.fromString("9007199254740993") // beyond Number.MAX_SAFE_INTEGER

JSON.stringify turns an ObjectId into its hex string and a Date into ISO text, which is fine for API responses. When data must round-trip losslessly (queues, caches), serialise with EJSON.stringify and EJSON.parse from the bson package.

TypeScript Support

The driver is typed generically. Declare the document shape once and every method is checked:

typescript
import type { WithId } from "mongodb"; interface Product { name: string; price: number; stock: number; tags?: string[] } const products = client.db("shop").collection<Product>("products"); const doc: WithId<Product> | null = await products.findOne({ name: "Lamp" }); await products.updateOne({ name: "Lamp" }, { $inc: { stock: -1 } }); // typo in a field name fails to compile

WithId<T> adds _id: ObjectId; OptionalId<T> describes insert payloads.

Handling Errors

| Error class | When | |---|---| | MongoServerError | the server rejected the operation; inspect code (11000 = duplicate key) and errInfo (validation) | | MongoBulkWriteError | some operations in insertMany/bulkWrite failed; see writeErrors and result | | MongoNetworkError / MongoServerSelectionError | cluster unreachable or no suitable server within the timeout |

javascript
try { await users.insertOne({ email }); } catch (err) { if (err.code === 11000) return res.status(409).json({ error: "Email already registered" }); throw err; }

Retryable writes and reads are on by default, so a single transient network blip is retried once by the driver before you see an error. For deeper observability enable monitorCommands: true and listen to commandStarted, commandSucceeded and commandFailed events on the client.

Common Mistakes

  • A new MongoClient per request. Share one instance; the pool handles concurrency.
  • Querying _id with a string received from a URL instead of new ObjectId(id).
  • toArray() on unbounded queries, loading millions of documents into memory.
  • Ignoring the promise. Every driver method is async; a missing await swallows errors.
Quick Quiz
Question 1 of 3

How many `MongoClient` instances should a typical Node.js service create?

Key Takeaways

  • Create one MongoClient per process, configure the pool and timeouts, and close it on shutdown.
  • Write methods resolve to result objects with counts and ids; find() and aggregate() return lazy cursors.
  • Consume large results with for await or stream() and reserve toArray() for small sets.
  • Convert route parameters with new ObjectId() and use Decimal128 for money.
  • Handle MongoServerError by code, MongoBulkWriteError by writeErrors, and rely on built-in retryable writes for transient failures.

Next lesson: MongoDB with Node.js and Mongoose — add schemas and models on top of the driver with the most popular ODM.