Module: MongoDB
MongoDB·144·7 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

You will learn to:

  • use the official mongodb Node.js driver directly;
  • create and reuse MongoClient correctly;
  • understand connection pools and server selection;
  • configure timeouts deliberately;
  • work with Db, Collection, ObjectId, Decimal128, Long, and BSON values;
  • perform CRUD without legacy callback APIs;
  • iterate cursors safely;
  • execute aggregations;
  • use sessions and transactions;
  • use change streams and resume tokens;
  • understand command monitoring and driver observability;
  • integrate the driver into Node application startup/shutdown;
  • avoid duplicate connection pools and query-owner mistakes.

Current baseline

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

Install:

bash
npm install mongodb

Do not use old tutorials that still show:

js
MongoClient.connect(uri, callback)

or deprecated helper methods such as:

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

Use the Promise-based APIs:

text
insertOne
insertMany
updateOne
updateMany
deleteOne
deleteMany

The driver is lower-level than Mongoose

The native driver gives you direct access to MongoDB concepts:

text
MongoClient
Db
Collection
Cursor
ClientSession
ChangeStream
BSON types

It does not automatically give you:

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

That is deliberate.

Many production systems use the native driver because they want:

  • 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

js
import {
  MongoClient,
} from 'mongodb';

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

Connect during application startup:

js
await client.connect();

Use:

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();
  ...
});

That creates unnecessary pools/connections and destroys the benefits of pooling.

A long-running Node service normally has:

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

Application composition

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();

Database lifecycle belongs to application lifecycle, not route modules.

Connection pools

The driver maintains pools.

Important concepts include:

text
maxPoolSize
minPoolSize
maxIdleTimeMS
waitQueueTimeoutMS

Exact defaults can change, so do not copy them from old tutorials.

Example:

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

Do not tune pool size arbitrarily.

Capacity equation:

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

If:

text
100 pods × maxPoolSize 100

you have designed for a potentially very large number of connections.

Measure:

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

A bigger pool does not make a slow query faster.

Server selection

The driver monitors MongoDB topology and selects an appropriate server.

During:

  • primary elections;
  • network partition;
  • unavailable cluster;

an operation may wait for server selection.

Important option:

text
serverSelectionTimeoutMS

Example:

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

Do not set 100 ms because “fast failure is good” if your replica-set failover can reasonably take longer.

Tie this to API timeout/SLA.

Connection timeout versus operation timeout

Different timeout layers solve different problems.

Examples:

text
serverSelectionTimeoutMS
connectTimeoutMS
socketTimeoutMS
maxTimeMS
application AbortSignal/deadline

Do not treat one timeout as “the Mongo timeout.”

A public search endpoint may use:

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

while application request has a 5-second overall deadline.

Database query design/indexes remain the primary fix for slow operations.

Stable API

MongoDB's Stable API can help applications target stable server command behavior across upgrades.

Conceptually:

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

Exact constants/API forms are available from driver.

Use Stable API where it benefits long-lived production compatibility.

It does not mean you never need upgrade testing.

Database and collection handles

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

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

Creating a handle does not necessarily perform network I/O immediately.

Network operation happens when a command/query is executed.

Do not “cache” millions of Collection objects; create/reuse repository references sensibly.

BSON types

Import:

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

ObjectId

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

Validate before converting.

A malformed ID should map to request validation, not an internal 500.

Decimal128

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

Do not convert Decimal128 to JS floating point and back if exact decimal precision matters.

Long

For exact 64-bit integer semantics:

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

That value is beyond JavaScript's safe integer limit.

Choose application serialization strategy intentionally.

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 developer tooling.

It does not validate runtime database documents automatically.

Database/application validation still matters.

Insert

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

console.log(result.insertedId);

Use inserted ID from result.

Insert many

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

Understand ordered versus unordered partial success.

For very large imports, batch intentionally.

Do not create a million-element array before insert if input is streamable.

Find one

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

Tenant scope belongs in filter.

Do not fetch by _id and then check tenant only in application if query can scope directly.

Find cursor

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

Iterate:

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

Use:

js
await cursor.toArray();

only when result is intentionally bounded.

Projection

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

Projection reduces transfer/hydration.

Do not accidentally omit fields needed for authorization/business rules.

Update

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

you need to distinguish:

text
not found
or
version conflict

You may issue a scoped existence check, or design repository semantics accordingly.

Do not expose cross-tenant existence while doing so.

findOneAndUpdate

Useful when you need updated document atomically:

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

Check current driver return semantics rather than assuming an older wrapper shape.

Delete

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

Treat deletion idempotency according to API contract.

Upsert

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.

Bulk write

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

Handle partial failures/duplicate-key errors intentionally.

Aggregate

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

Use pipeline lessons from 138.

Native driver does not make a bad aggregation efficient.

Command options

Many operations support options such as:

text
projection
sort
hint
collation
comment
maxTimeMS
readPreference
readConcern
writeConcern
session

Use only with documented reason.

hint can force an index and make future data/index changes worse; do not use as first tuning move.

Sessions

js
const session = client.startSession();

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

A session is not a global singleton.

Do not reuse the same ClientSession concurrently across unrelated requests.

Transactions

Use helper:

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 transaction must receive the same session.

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

Transaction callback caution

The transaction callback may be retried by the helper for selected transient errors.

Therefore do not perform non-idempotent external side effects inside:

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

The callback may run again.

Keep external side effects outside Mongo transaction and use outbox/idempotency/saga architecture.

Read/write concern

Driver options can specify concerns:

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

Use explicit concern only when application semantics require.

Do not scatter different concerns across queries with no documentation.

Change streams

Watch collection:

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

Consume:

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

Change streams require replica-set or sharded topology.

They are not available on ordinary standalone deployments.

Resume token

Each change event includes resume information.

Persist a resume token if your application needs restart continuity:

js
lastResumeToken = change._id;

Then reopen with supported resume option.

Do not only store token in process memory if process-crash continuity matters.

Change stream failure policy

Decide:

text
resume
rebuild projection
alert
stop service

Resume token can become invalid if history is no longer available or topology/data changes require fallback.

For critical projection:

text
baseline snapshot
+
resume token
+
idempotent event application

can be safer than “listen forever.”

Pre/post images

MongoDB can provide document pre/post images for change streams when configured/supported.

This has storage/privilege/retention implications.

Enable only where business needs previous/new complete document.

Change streams versus outbox

Change stream:

text
database change happened

Outbox:

text
business event intentionally recorded

They are not identical.

A raw order document update may not encode the exact business event consumers need.

Command monitoring

Driver exposes events for command monitoring and pool/topology diagnostics.

This is useful for:

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

Do not log command payloads blindly because they may contain user data/secrets.

Prefer tracing integration or sanitized event metadata.

Logging driver behavior

MongoDB driver has current logging/monitoring features.

Use:

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

Do not enable verbose command logging permanently in production without privacy/cost review.

Graceful shutdown

During SIGTERM:

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

Do not call client.close() while new routes still accept requests.

Serverless

In serverless functions, reuse client across warm invocations when platform/module lifetime allows.

Do not connect/close on every tiny query if it destroys pooling.

But never share mutable per-request session/auth state globally.

Follow MongoDB's current serverless guidance for your platform.

Lambda/container differences

Long-running container:

text
startup connect
serve many
shutdown close

Serverless:

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

Architecture differs.

Failure clinic

New MongoClient in repository function

Pool explosion.

toArray() on unbounded find

Memory explosion.

ObjectId string used without conversion

No match or inconsistent data.

transaction callback calls payment API

Duplicate side effect on retry.

one session shared across concurrent HTTP requests

Incorrect concurrency/lifecycle.

connection URI logged

Credential leak.

no tenant in query

Cross-tenant risk.

change stream treated as guaranteed business queue

Missing business event semantics/recovery.

Repository example

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();
    },
  };
}

HTTP does not import MongoDB.

Service does not know response object.

Repository owns database syntax.

Exercises

  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

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