144: MongoDB Native Node.js Driver — MongoClient, Pools, BSON, Cursors, Sessions, Transactions, and Change Streams
Learning objectives
You will learn to:
- use the official
mongodbNode.js driver directly; - create and reuse
MongoClientcorrectly; - 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:
npm install mongodb
Do not use old tutorials that still show:
MongoClient.connect(uri, callback)
or deprecated helper methods such as:
collection.insert() collection.update() collection.remove()
Use the Promise-based APIs:
insertOne insertMany updateOne updateMany deleteOne deleteMany
The driver is lower-level than Mongoose
The native driver gives you direct access to MongoDB concepts:
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
import {
MongoClient,
} from 'mongodb';
const client = new MongoClient(
process.env.MONGODB_URI,
);
Connect during application startup:
await client.connect();
Use:
const db = client.db('course');
const tasks = db.collection('tasks');
Do not create a new MongoClient for every HTTP request:
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:
one MongoClient per application process → one pool per relevant server/topology → many operations borrow connections
Application composition
export async function createDatabase({
uri,
databaseName,
}) {
const client = new MongoClient(uri);
await client.connect();
return {
client,
db: client.db(databaseName),
};
}
Startup:
const database = await createDatabase(config.mongodb);
const app = createApp({
taskRepository: createMongoTaskRepository(
database.db,
),
});
Shutdown:
await database.client.close();
Database lifecycle belongs to application lifecycle, not route modules.
Connection pools
The driver maintains pools.
Important concepts include:
maxPoolSize minPoolSize maxIdleTimeMS waitQueueTimeoutMS
Exact defaults can change, so do not copy them from old tutorials.
Example:
const client = new MongoClient(uri, {
maxPoolSize: 30,
minPoolSize: 0,
maxIdleTimeMS: 60_000,
});
Do not tune pool size arbitrarily.
Capacity equation:
potential connections ≈ app instances × per-instance pool capacity × topology behavior
If:
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:
serverSelectionTimeoutMS
Example:
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:
serverSelectionTimeoutMS connectTimeoutMS socketTimeoutMS maxTimeMS application AbortSignal/deadline
Do not treat one timeout as “the Mongo timeout.”
A public search endpoint may use:
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:
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
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:
import {
ObjectId,
Decimal128,
Long,
Binary,
} from 'mongodb';
ObjectId
const taskId = ObjectId.createFromHexString(
rawTaskId,
);
Validate before converting.
A malformed ID should map to request validation, not an internal 500.
Decimal128
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:
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:
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
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
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
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
const cursor = tasks
.find({
tenantId,
completed: false,
})
.sort({
createdAt: -1,
_id: -1,
})
.limit(50);
Iterate:
for await (const task of cursor) {
...
}
Use:
await cursor.toArray();
only when result is intentionally bounded.
Projection
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
const result = await tasks.updateOne(
{
_id: taskId,
tenantId,
version: expectedVersion,
},
{
$set: {
title: input.title,
updatedAt: new Date(),
},
$inc: {
version: 1,
},
},
);
If:
result.matchedCount === 0
you need to distinguish:
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:
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
const result = await tasks.deleteOne({
_id: taskId,
tenantId,
});
Treat deletion idempotency according to API contract.
Upsert
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
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
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:
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
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:
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:
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:
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:
const stream = tasks.watch([
{
$match: {
operationType: {
$in: [
'insert',
'update',
'replace',
'delete',
],
},
},
},
]);
Consume:
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:
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:
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:
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:
database change happened
Outbox:
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:
- stop accepting HTTP;
- stop background change streams/jobs;
- close change stream/cursors;
- finish bounded requests;
- close MongoClient.
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:
startup connect serve many shutdown close
Serverless:
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
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
- Build one application-scoped MongoClient.
- Create typed/validated ObjectId parser.
- Implement task repository CRUD.
- Stream cursor instead of
toArrayfor export. - Add operation timeout.
- Build optimistic version update.
- Implement a
withTransactiontransfer. - Demonstrate why external side effect cannot live in retryable transaction callback.
- Open change stream and persist resume token.
- Add graceful Mongo client shutdown.
- 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
- https://www.mongodb.com/docs/drivers/node/current/
- https://www.mongodb.com/docs/drivers/node/current/connect/
- https://www.mongodb.com/docs/drivers/node/current/crud/
- https://www.mongodb.com/docs/drivers/node/current/fundamentals/transactions/
- https://www.mongodb.com/docs/drivers/node/current/monitoring-and-logging/
- https://www.mongodb.com/docs/manual/changeStreams/
- https://roadmap.sh/mongodb
