FullStack Course LogoFullStack Course
Module: MongoDB
MongoDB·144·12 MIN READ

144: MongoDB Native Node.js Driver — MongoClient, Pools, BSON, Cursors, Sessions, Transactions, and Change Streams

TOPICS COVERED: MongoDB Native Node.js Driver — MongoClient, Pools, BSON, Cursors, Sessions, Transactions, and Change Streams

Learning objectives

By the end of this lesson, you should be able to:

  • use the official mongodb Node.js driver directly;
  • create and reuse MongoClient correctly;
  • reason about connection pools and server selection;
  • choose timeout settings deliberately instead of treating them as one generic database timeout;
  • work with Db, Collection, ObjectId, Decimal128, Long, and other BSON values;
  • perform CRUD with the modern Promise-based API rather than legacy callbacks;
  • iterate cursors safely;
  • execute aggregation pipelines;
  • use sessions and transactions;
  • use change streams and persist resume tokens;
  • understand command monitoring and driver observability;
  • integrate the driver with Node application startup and shutdown;
  • avoid duplicate connection pools and mistakes about which layer owns a query.

Current baseline

This lesson uses the modern official MongoDB Node.js driver. As of August 2026, MongoDB documents the 7.x driver line, including 7.5.

Install it with:

bash
npm install mongodb

Be careful with older tutorials. Some still teach the callback form:

js
MongoClient.connect(uri, callback)

or deprecated helper methods such as:

text
collection.insert()
collection.update()
collection.remove()

Use the Promise-based methods instead:

text
insertOne
insertMany
updateOne
updateMany
deleteOne
deleteMany

The driver is lower-level than Mongoose

The native driver exposes MongoDB's concepts directly. The main handles you will work with are:

text
MongoClient
Db
Collection
Cursor
ClientSession
ChangeStream
BSON types

That directness also means the driver does not automatically provide:

  • application schema classes;
  • document middleware;
  • virtual properties;
  • ODM population;
  • model validation.

This is intentional rather than an omission.

Many production systems choose the native driver because they need:

  • explicit queries;
  • fewer abstractions;
  • direct control;
  • lower overhead;
  • close alignment with MongoDB documentation.

Mongoose is covered in lesson 145.

Create one MongoClient for the application

Create the client at application composition time, not inside each request handler:

js
import {
  MongoClient,
} from 'mongodb';

const client = new MongoClient(
  process.env.MONGODB_URI,
);

Connect during application startup:

js
await client.connect();

Once connected, obtain the database and collection handles you need:

js
const db = client.db('course');
const tasks = db.collection('tasks');

Do not create a new MongoClient for every HTTP request:

js
app.get('/tasks', async (req, res) => {
  const client = new MongoClient(uri);
  await client.connect();
  ...
});

Every such client manages its own pool. Creating one per request adds connection and handshake work, can exhaust the database's connection capacity, and throws away the benefit of pooling.

A long-running Node service normally follows this model:

text
one MongoClient per application process
→ one pool per relevant server/topology
→ many operations borrow connections

The client is application infrastructure. Individual operations borrow connections from it and return them to the pool.

Application composition

Keep database construction explicit so the rest of the application can receive a ready dependency:

js
export async function createDatabase({
  uri,
  databaseName,
}) {
  const client = new MongoClient(uri);

  await client.connect();

  return {
    client,
    db: client.db(databaseName),
  };
}

Startup:

js
const database = await createDatabase(config.mongodb);

const app = createApp({
  taskRepository: createMongoTaskRepository(
    database.db,
  ),
});

Shutdown:

js
await database.client.close();

The database lifecycle belongs to the application lifecycle. Route modules should not independently create and destroy the database connection.

Connection pools

The driver maintains connection pools behind the MongoClient. The settings most often involved in capacity and waiting behavior include:

text
maxPoolSize
minPoolSize
maxIdleTimeMS
waitQueueTimeoutMS

Exact defaults can change between driver versions, so do not copy a number from an old tutorial without checking the current documentation.

For example:

js
const client = new MongoClient(uri, {
  maxPoolSize: 30,
  minPoolSize: 0,
  maxIdleTimeMS: 60_000,
});

Pool size is a capacity decision, not a number to increase arbitrarily. A useful first approximation is:

text
potential connections
≈ app instances
× per-instance pool capacity
× topology behavior

So this deployment:

text
100 pods × maxPoolSize 100

has been configured for a potentially very large number of database connections. The actual topology and driver behavior matter, but the multiplication is enough to expose an obvious capacity risk.

Measure before tuning:

  • request concurrency;
  • DB operation duration;
  • deployment instance count;
  • Atlas/server connection limits;
  • wait queue;
  • CPU.

A larger pool does not make a slow query execute faster. It only gives more operations an opportunity to run concurrently, which can make an overloaded database worse.

Server selection

The driver monitors the MongoDB topology and selects an appropriate server for each operation. During a primary election, a network partition, or an unavailable cluster, the operation may wait while the driver looks for a usable server.

The option that bounds this waiting period is:

text
serverSelectionTimeoutMS

For example:

js
new MongoClient(uri, {
  serverSelectionTimeoutMS: 5_000,
});

Do not set this to 100 ms merely because “fast failure is good.” If replica-set failover can reasonably take longer, that setting converts a recoverable topology event into application errors. Tie the value to the API timeout and service-level objective instead.

Connection timeout versus operation timeout

MongoDB exposes several timeout layers, and each answers a different question:

text
serverSelectionTimeoutMS
connectTimeoutMS
socketTimeoutMS
maxTimeMS
application AbortSignal/deadline

For example, serverSelectionTimeoutMS concerns finding a server, while maxTimeMS limits execution of a database operation. The application deadline covers the whole request, including validation, database work, and response handling. Do not refer to one of these as “the Mongo timeout.”

A public search endpoint might cap its database work like this:

js
collection.find(filter, {
  maxTimeMS: 2_000,
});

while the application gives the complete request a five-second deadline.

Query design and indexes remain the primary fix for slow operations. Timeouts limit damage and define behavior; they do not optimize a poor query.

Stable API

MongoDB's Stable API can help an application target stable server command behavior across upgrades. Conceptually, a client can be configured like this:

js
const client = new MongoClient(uri, {
  serverApi: {
    version: '1',
    strict: true,
    deprecationErrors: true,
  },
});

Use the exact constants and API form provided by the driver version you install. The driver documentation is the authority for those details.

Stable API is useful for long-lived production compatibility, but it does not remove the need for upgrade testing.

Database and collection handles

js
const db = client.db('commerce');

const orders = db.collection('orders');

Creating these handles does not necessarily perform network I/O immediately. The network operation occurs when a command or query is executed.

That does not mean you should cache millions of handles. Create and reuse repository references sensibly; the client and its pools are the resources that need deliberate lifecycle management.

BSON types

MongoDB values are not always interchangeable with JavaScript primitives. Import the BSON helpers you need:

js
import {
  ObjectId,
  Decimal128,
  Long,
  Binary,
} from 'mongodb';

ObjectId

Convert an incoming identifier only after validating its format:

js
const taskId = ObjectId.createFromHexString(
  rawTaskId,
);

A malformed ID is an input-validation problem. It should normally become a client validation response, not an internal 500 error.

Decimal128

For exact decimal values, construct a Decimal128 from its string representation:

js
const amount = Decimal128.fromString(
  '125.50',
);

Do not convert Decimal128 to a JavaScript floating-point number and then back when exact decimal precision matters. That round trip can introduce a value that was not present in the database.

Long

For exact 64-bit integer semantics, use Long deliberately:

js
const value = Long.fromString(
  '9007199254740993',
);

That value is beyond JavaScript's safe integer limit. Decide how the value will be serialized at application boundaries instead of letting a generic JSON conversion silently lose precision.

TypeScript generics

The driver supports typed collection schemas in TypeScript:

ts
interface Task {
  _id: ObjectId;
  tenantId: ObjectId;
  title: string;
  completed: boolean;
}

const tasks = db.collection<Task>('tasks');

This improves editor support and catches many mistakes while developing. It does not validate documents received from the database at runtime. Database validation and application-level runtime validation still matter.

Insert

Insert a document and use the identifier returned by the driver:

js
const result = await tasks.insertOne({
  tenantId,
  title: input.title,
  completed: false,
  version: 1,
  createdAt: new Date(),
  updatedAt: new Date(),
});

console.log(result.insertedId);

The returned insertedId is the authoritative ID for the inserted document.

Insert many

js
await tasks.insertMany(
  documents,
  {
    ordered: false,
  },
);

Understand the trade-off between ordered and unordered inserts. An unordered operation can continue with other documents after an individual failure, so partial success and duplicate-key errors must be handled explicitly.

For very large imports, choose batch sizes intentionally. Do not build a million-element array first when the input can be streamed.

Find one

js
const task = await tasks.findOne({
  _id: taskId,
  tenantId,
});

The tenant scope belongs in the database filter. Do not fetch by _id and rely on a later application check when the query itself can enforce the scope. Keeping the boundary in the filter reduces the chance that a future code path returns another tenant's document.

Find cursor

find() returns a cursor, which represents a potentially larger result set and fetches batches as needed:

js
const cursor = tasks
  .find({
    tenantId,
    completed: false,
  })
  .sort({
    createdAt: -1,
    _id: -1,
  })
  .limit(50);

Iterate it incrementally:

js
for await (const task of cursor) {
  ...
}

toArray() is convenient:

js
await cursor.toArray();

but use it only when the result is intentionally bounded. Calling it on an unbounded query is a common path to a memory explosion.

Projection

Request only the fields the operation actually needs:

js
const task = await tasks.findOne(
  {
    _id: taskId,
    tenantId,
  },
  {
    projection: {
      title: 1,
      completed: 1,
      version: 1,
    },
  },
);

Projection reduces data transfer and hydration work. Do not accidentally exclude fields required for authorization or business rules merely because the response does not expose them.

Update

An optimistic version check can make concurrent edits visible:

js
const result = await tasks.updateOne(
  {
    _id: taskId,
    tenantId,
    version: expectedVersion,
  },
  {
    $set: {
      title: input.title,
      updatedAt: new Date(),
    },
    $inc: {
      version: 1,
    },
  },
);

If:

js
result.matchedCount === 0

the result is ambiguous. It may mean:

text
not found
or
version conflict

You can issue a scoped existence check, or define repository semantics that intentionally expose only one outcome. Do not let that diagnostic accidentally reveal that a document exists in another tenant.

findOneAndUpdate

Use this operation when you need the updated document as part of the atomic update:

js
const updated = await tasks.findOneAndUpdate(
  {
    _id: taskId,
    tenantId,
  },
  {
    $set: {
      completed: true,
      updatedAt: new Date(),
    },
  },
  {
    returnDocument: 'after',
  },
);

Check the current driver's return semantics. Do not assume that an older wrapper's result shape still applies.

Delete

js
const result = await tasks.deleteOne({
  _id: taskId,
  tenantId,
});

Whether deleting an already-missing document is success or an error depends on the API contract. Treat deletion idempotency as an explicit design choice.

Upsert

An upsert is useful for idempotent external-event ingestion, but the query and index must agree on what makes an event unique:

js
await externalEvents.updateOne(
  {
    tenantId,
    externalId,
  },
  {
    $setOnInsert: {
      tenantId,
      externalId,
      createdAt: new Date(),
    },
    $set: {
      payload: normalizedPayload,
      updatedAt: new Date(),
    },
  },
  {
    upsert: true,
  },
);

Use a unique index to make concurrency correctness authoritative. Application intent alone cannot prevent two concurrent writers from creating duplicates.

Bulk write

js
await tasks.bulkWrite([
  {
    updateOne: {
      filter: {
        _id: taskA,
        tenantId,
      },
      update: {
        $set: {
          completed: true,
        },
      },
    },
  },
  {
    deleteOne: {
      filter: {
        _id: taskB,
        tenantId,
      },
    },
  },
]);

Bulk operations can have partial failures. Handle duplicate-key errors and the driver's result details intentionally rather than treating every failure as an all-or-nothing outcome.

Aggregate

The native driver sends an aggregation pipeline; it does not make an inefficient pipeline efficient. For example:

js
const results = await orders
  .aggregate([
    {
      $match: {
        tenantId,
        createdAt: {
          $gte: start,
          $lt: end,
        },
      },
    },
    {
      $group: {
        _id: '$status',
        count: {
          $sum: 1,
        },
        totalPaise: {
          $sum: '$totalPaise',
        },
      },
    },
  ])
  .toArray();

Use the pipeline techniques from lesson 138. In particular, reason about filtering, indexes, cardinality, and the amount of data each stage passes onward.

Command options

Many operations support options such as:

text
projection
sort
hint
collation
comment
maxTimeMS
readPreference
readConcern
writeConcern
session

Use an option when you have a documented reason for it. hint can force an index and make future data or index changes perform worse, so it should not be the first tuning move.

Sessions

Create and close a session within the operation boundary that owns it:

js
const session = client.startSession();

try {
  ...
} finally {
  await session.endSession();
}

A session is not a global singleton. Never reuse the same ClientSession concurrently across unrelated requests; its lifecycle and state belong to the operation using it.

Transactions

The driver's helper handles transaction setup and selected retry behavior:

js
const session = client.startSession();

try {
  await session.withTransaction(
    async () => {
      await accounts.updateOne(
        {
          _id: fromId,
          balancePaise: {
            $gte: amount,
          },
        },
        {
          $inc: {
            balancePaise: -amount,
          },
        },
        {
          session,
        },
      );

      await accounts.updateOne(
        {
          _id: toId,
        },
        {
          $inc: {
            balancePaise: amount,
          },
        },
        {
          session,
        },
      );

      await transfers.insertOne(
        transferDocument,
        {
          session,
        },
      );
    },
    {
      // transaction options when business needs them
    },
  );
} finally {
  await session.endSession();
}

Every operation that belongs to the transaction must receive the same session. One missing session option silently places that operation outside the transaction boundary.

Do not start parallel operations inside one transaction unless the driver and server contract explicitly supports that pattern. Keep transaction code sequential and short.

Transaction callback caution

The withTransaction callback may be retried for selected transient errors. That means the callback can run more than once:

js
withTransaction(async () => {
  await chargeCreditCard();
});

A non-idempotent external payment call in that callback could charge the customer twice. Keep external side effects outside the MongoDB transaction and coordinate them with an outbox, idempotency, or saga architecture.

Read/write concern

Driver options can specify read and write concerns. For example:

js
const collection = db.collection(
  'criticalRecords',
  {
    writeConcern: {
      w: 'majority',
    },
  },
);

Use an explicit concern when the application's consistency or durability semantics require it. Do not scatter different concerns across queries without documenting why they differ.

Change streams

A collection change stream can filter the operations it delivers:

js
const stream = tasks.watch([
  {
    $match: {
      operationType: {
        $in: [
          'insert',
          'update',
          'replace',
          'delete',
        ],
      },
    },
  },
]);

Consume the stream asynchronously:

js
for await (const change of stream) {
  console.log(change);
}

Change streams require a replica-set or sharded topology. They are not available on an ordinary standalone deployment.

Resume token

Each change event includes resume information. If restart continuity matters, persist the token rather than keeping it only in process memory:

js
lastResumeToken = change._id;

Reopen the stream with the supported resume option. A process-memory-only token disappears during a crash, exactly when continuity is most useful.

Change stream failure policy

Before shipping a consumer, decide what it does when the stream fails:

text
resume
rebuild projection
alert
stop service

A resume token can become invalid when history is no longer available, or when topology or data changes require a fallback. For a critical projection, this pattern is often safer than simply “listening forever”:

text
baseline snapshot
+
resume token
+
idempotent event application

The snapshot establishes a known starting point, the token identifies the continuation point, and idempotent application makes retries survivable.

Pre/post images

MongoDB can provide document pre/post images for change streams when the feature is configured and supported.

That capability has storage, privilege, and retention implications. Enable it only when the business actually needs the previous or new complete document.

Change streams versus outbox

These mechanisms communicate different facts:

Change stream:

text
database change happened

Outbox:

text
business event intentionally recorded

They are not interchangeable. A raw order-document update may not capture the exact business event that downstream consumers need, whereas an outbox entry can record that intent explicitly in the same business operation.

Command monitoring

The driver exposes command-monitoring events and pool/topology diagnostics. They are useful for:

  • latency;
  • APM;
  • debugging;
  • connection pool events.

Do not log command payloads blindly. Query values may contain user data or secrets. Prefer tracing integration or sanitized event metadata that preserves timing and operation identity without copying sensitive content into logs.

Logging driver behavior

The MongoDB driver has current logging and monitoring features. Configure them with:

  • a safe log level;
  • redaction;
  • environment-specific verbosity.

Do not leave verbose command logging enabled permanently in production without reviewing privacy exposure and logging cost.

Graceful shutdown

During SIGTERM, shut down in an order that prevents new work from racing with resource cleanup:

  1. stop accepting HTTP;
  2. stop background change streams/jobs;
  3. close change stream/cursors;
  4. finish bounded requests;
  5. close MongoClient.

Finally:

js
await client.close();

Do not call client.close() while routes are still accepting requests. Otherwise requests can race with pool teardown and fail unpredictably.

Serverless

In a serverless function, reuse the client across warm invocations when the platform and module lifetime allow it. Connecting and closing for every tiny query can destroy pooling and add avoidable latency.

At the same time, never share mutable per-request session or authentication state globally. Reuse the client resource, not request-specific state.

Follow MongoDB's current serverless guidance for the platform you deploy on.

Lambda/container differences

A long-running container generally follows:

text
startup connect
serve many
shutdown close

A serverless function generally follows:

text
cold start
reuse module/client on warm invocation
platform freezes/reuses

The lifecycle model differs, so connection setup and cleanup code should reflect the execution environment rather than assuming that a container and a Lambda invocation behave alike.

Failure clinic

New MongoClient in repository function

This creates a pool repeatedly and can lead to a pool explosion.

toArray() on unbounded find

This materializes too much data and can cause a memory explosion.

ObjectId string used without conversion

The BSON type does not match the stored value, producing no match or inconsistent data.

transaction callback calls payment API

The callback can be retried, producing a duplicate external side effect.

one session shared across concurrent HTTP requests

The session's concurrency and lifecycle assumptions are violated.

connection URI logged

The URI may contain credentials, causing a credential leak.

no tenant in query

The filter can return another tenant's data, creating cross-tenant risk.

change stream treated as guaranteed business queue

A database change is not automatically a complete business event or a durable recovery strategy.

Repository example

The repository owns MongoDB syntax while the HTTP and service layers remain independent of it:

js
export function createTaskRepository(db) {
  const collection = db.collection('tasks');

  return {
    async findById({
      tenantId,
      taskId,
    }) {
      return collection.findOne({
        tenantId,
        _id: taskId,
      });
    },

    async list({
      tenantId,
      limit,
    }) {
      return collection
        .find({
          tenantId,
        })
        .sort({
          createdAt: -1,
          _id: -1,
        })
        .limit(limit)
        .toArray();
    },
  };
}

The resulting boundaries are:

HTTP does not import MongoDB.

Service does not know the response object.

Repository owns database syntax.

Exercises

Work through these in order; together they move from lifecycle and types to concurrency, transactions, streaming, and operations:

  1. Build one application-scoped MongoClient.
  2. Create typed/validated ObjectId parser.
  3. Implement task repository CRUD.
  4. Stream cursor instead of toArray for export.
  5. Add operation timeout.
  6. Build optimistic version update.
  7. Implement a withTransaction transfer.
  8. Demonstrate why external side effect cannot live in retryable transaction callback.
  9. Open change stream and persist resume token.
  10. Add graceful Mongo client shutdown.
  11. Calculate pool capacity for 40 Node instances.

Mastery checklist

You should be able to explain:

  • MongoClient lifecycle;
  • connection pools;
  • server selection/timeouts;
  • BSON driver types;
  • cursors;
  • CRUD/aggregation;
  • sessions;
  • transactions/retries;
  • change streams/resume;
  • driver monitoring;
  • serverless reuse;
  • repository boundary.

Official references

Reader page: /mongodb/lesson/144/mongodb-native-node-js-driver-mongoclient-pools-bson-cursors-sessions-transactions-and-change-streams