Advanced Mongoose
Learn performance techniques in Mongoose — lean(), field selection, transactions with sessions, and bulk operations with bulkWrite().
Advanced Mongoose
This final file covers techniques for making Mongoose faster and more efficient in real applications — lean() for read performance, transactions for multi-document operations, and bulkWrite() for handling many writes efficiently.
lean()
By default, every document Mongoose returns is a full Mongoose Document — an object with extra methods like .save(), .populate(), getters, setters, and virtuals. Building these objects has overhead.
.lean() tells Mongoose — skip all that, just give me a plain JavaScript object.
Without lean()
const students = await Student.find({ enrolled: true });
// Each student is a full Mongoose document
console.log(students[0] instanceof mongoose.Document); // true
console.log(typeof students[0].save); // "function"With lean()
const students = await Student.find({ enrolled: true }).lean();
// Each student is a plain JavaScript object
console.log(students[0] instanceof mongoose.Document); // false
console.log(typeof students[0].save); // "undefined" — no Mongoose methodsWhy Use lean()
Faster — Mongoose does not have to build full document instances with getters, setters, and methods. For large result sets, this is noticeably faster.
Less memory — plain objects use less memory than Mongoose documents.
// Performance comparison — fetching 10,000 students
console.time('without lean');
const students1 = await Student.find({ enrolled: true });
console.timeEnd('without lean'); // e.g. 450ms
console.time('with lean');
const students2 = await Student.find({ enrolled: true }).lean();
console.timeEnd('with lean'); // e.g. 180msWhen to Use lean()
Use .lean() when:
- You are only reading data — displaying a list, generating a report, sending an API response
- You do not need to call
.save()on the result - You do not need virtuals (unless using
lean({ virtuals: true })— Mongoose 6+) - Performance matters — large result sets, high-traffic endpoints
// Good use of lean — read-only API endpoint
app.get('/students', async (req, res) => {
const students = await Student
.find({ enrolled: true })
.select("name grade age")
.lean();
res.json(students);
});When NOT to Use lean()
Do not use .lean() when:
- You need to call
.save()on the returned document - You need virtuals, getters, or document methods
- You need Mongoose's automatic type casting on the result
// Bad — .save() does not exist on lean documents
const student = await Student.findOne({ name: "Ali Hassan" }).lean();
student.age = 17;
await student.save(); // ERROR — student.save is not a function
// Correct — without lean, when you need to modify and save
const student = await Student.findOne({ name: "Ali Hassan" });
student.age = 17;
await student.save(); // worksA good rule of thumb — use .lean() for any query where the result is only being read or sent in an API response. Skip .lean() only when you plan to modify and save the document, or specifically need virtuals and Mongoose document features.
select() for Performance
We covered .select() in the Queries file for choosing which fields to return. It is also a performance technique — fetching less data means less work for MongoDB, less data over the network, and less memory in your app.
// Fetches the entire document — including large embedded arrays
const students = await Student.find({ enrolled: true });
// Fetches only what's needed for a list view
const students = await Student
.find({ enrolled: true })
.select("name grade age")
.lean();Combining lean() and select()
These two work very well together — .select() reduces the data fetched, .lean() reduces the overhead of building documents:
// Optimized list query
const students = await Student
.find({ enrolled: true, grade: "10th" })
.select("name age address.city -_id")
.sort("name")
.limit(20)
.lean();Transactions with Mongoose
We covered transactions in depth in the Transactions section using the raw MongoDB driver. With Mongoose, the concept is identical — you start a session, run operations within it, and commit or abort. The syntax differs slightly — Mongoose models use .session(session).
Basic Pattern
const session = await mongoose.startSession();
try {
await session.withTransaction(async () => {
// operations here, each using .session(session)
});
} catch (error) {
console.error('Transaction failed:', error.message);
throw error;
} finally {
await session.endSession();
}Real Example — Enrolling a Student
const mongoose = require('mongoose');
const Student = require('./models/Student');
const Course = require('./models/Course');
const Enrollment = require('./models/Enrollment');
async function enrollStudent(studentId, courseId) {
const session = await mongoose.startSession();
try {
await session.withTransaction(async () => {
const course = await Course.findById(courseId).session(session);
if (!course) throw new Error('Course not found');
if (course.totalStudents >= course.capacity) {
throw new Error('Course is full');
}
// Add course to student
await Student.updateOne(
{ _id: studentId },
{ $push: { courseIds: course._id } }
).session(session);
// Increment course student count
await Course.updateOne(
{ _id: course._id },
{ $inc: { totalStudents: 1 } }
).session(session);
// Create enrollment record
// Note: create() with sessions requires an array + options object
await Enrollment.create(
[{
studentId,
courseId: course._id,
enrolledAt: new Date(),
status: "active"
}],
{ session }
);
});
console.log('Enrollment successful');
} finally {
await session.endSession();
}
}Notice Enrollment.create([{...}], { session }) — when using sessions, create() requires the documents to be passed as an array, even for a single document. This is different from create() without a session, where you can pass a single object directly.
Find with Session
// Reading inside a transaction — also needs the session
const course = await Course.findOne({ code: "MATH-10" }).session(session);save() with Session
const student = await Student.findById(studentId).session(session);
student.courseIds.push(courseId);
await student.save({ session }); // pass session as an option to save()bulkWrite()
When you need to perform many different write operations — inserts, updates, deletes — in a single batch, bulkWrite() sends them all in one request to MongoDB instead of one network round trip per operation.
Syntax
await Model.bulkWrite([
{ insertOne: { document: {...} } },
{ updateOne: { filter: {...}, update: {...} } },
{ updateMany: { filter: {...}, update: {...} } },
{ deleteOne: { filter: {...} } },
{ deleteMany: { filter: {...} } },
{ replaceOne: { filter: {...}, replacement: {...} } }
])Example — Bulk Update Grades After an Exam
Imagine a teacher uploads a spreadsheet of exam results. Instead of calling updateOne() 30 times — once per student — use bulkWrite():
const examResults = [
{ studentId: "64a1f2c3e4b0a1b2c3d4e5f1", subject: "Math", score: 88 },
{ studentId: "64a1f2c3e4b0a1b2c3d4e5f2", subject: "Math", score: 76 },
{ studentId: "64a1f2c3e4b0a1b2c3d4e5f3", subject: "Math", score: 95 },
// ... 30 more
];
const operations = examResults.map(result => ({
updateOne: {
filter: { _id: result.studentId },
update: {
$push: {
grades: {
subject: result.subject,
score: result.score,
semester: 1
}
}
}
}
}));
const bulkResult = await Student.bulkWrite(operations);
console.log(`Matched: ${bulkResult.matchedCount}`);
console.log(`Modified: ${bulkResult.modifiedCount}`);One network request updates all 30+ students, instead of 30+ separate requests.
Example — Mixed Operations
await Student.bulkWrite([
// Insert a new student
{
insertOne: {
document: {
name: "New Student",
age: 15,
grade: "9th",
enrolled: true
}
}
},
// Update an existing student
{
updateOne: {
filter: { name: "Ali Hassan" },
update: { $set: { grade: "11th" } }
}
},
// Mark a student as not enrolled
{
updateOne: {
filter: { name: "Bilal Ahmed" },
update: { $set: { enrolled: false } }
}
},
// Delete a test record
{
deleteOne: {
filter: { name: "Test Student" }
}
}
])bulkWrite() Result
{
insertedCount: 1,
matchedCount: 2,
modifiedCount: 2,
deletedCount: 1,
upsertedCount: 0
}ordered Option
By default, bulkWrite() runs operations in order and stops at the first error. Pass { ordered: false } to continue executing remaining operations even if one fails:
await Student.bulkWrite(operations, { ordered: false });This works the same way as insertMany()'s ordered option, covered in the CRUD section.
bulkWrite() does NOT trigger Mongoose middleware like pre('save') — it talks more directly to MongoDB. If your schema relies on pre('save') hooks for calculations or validation, those will not run during a bulkWrite(). Use bulkWrite() for simple, high-volume operations where middleware is not needed.
Combining Everything — A Real Endpoint
Here is a school system API endpoint that uses lean(), select(), and pagination together for an optimized student list:
app.get('/api/students', async (req, res) => {
const page = parseInt(req.query.page) || 1;
const pageSize = parseInt(req.query.limit) || 20;
const grade = req.query.grade;
const filter = { enrolled: true };
if (grade) filter.grade = grade;
const skip = (page - 1) * pageSize;
const [students, totalCount] = await Promise.all([
Student
.find(filter)
.select("name grade age address.city -_id")
.sort("name")
.skip(skip)
.limit(pageSize)
.lean(),
Student.countDocuments(filter)
]);
res.json({
data: students,
pagination: {
currentPage: page,
totalCount,
totalPages: Math.ceil(totalCount / pageSize)
}
});
});And a bulk grade update endpoint using bulkWrite():
app.post('/api/grades/bulk', async (req, res) => {
const { results, semester } = req.body;
// results = [{ studentId, subject, score }, ...]
const operations = results.map(r => ({
updateOne: {
filter: { _id: r.studentId },
update: {
$push: {
grades: { subject: r.subject, score: r.score, semester }
}
}
}
}));
const result = await Student.bulkWrite(operations);
res.json({
message: 'Grades updated',
modified: result.modifiedCount
});
});Quick Reference
| Technique | What it does | Use when |
|---|---|---|
.lean() | Returns plain JS objects instead of Mongoose documents | Read-only queries, list views, API responses |
.select() | Limits fields returned | Reduce data transfer and memory |
session.withTransaction() | Groups multiple writes atomically | Multi-document operations that must succeed together |
Model.bulkWrite() | Batches many write operations into one request | Bulk inserts, updates, or deletes |
Mongoose Section Complete
This completes the Mongoose section. Here is everything we covered:
what-is-mongoose → ODM concept, installation, basic workflow
connecting → mongoose.connect(), connection events, app structure
schemas → SchemaTypes, defaults, options, timestamps
models → Full CRUD with models
queries → Chainable query API, where, select, sort
validation → Built-in and custom validators, error handling
middleware → pre/post hooks, document vs query middleware
population → populate(), nested populate, virtual populate
virtuals → Computed fields, getters and setters
pagination → Skip/limit and cursor-based pagination
advanced → lean(), select(), transactions, bulkWrite()Together, these give you everything needed to build a real, production-ready data layer for a Node.js application using MongoDB.
As a final habit — for every new query you write, ask two questions: "Do I need to modify and save this document?" and "Do I need every field in this document?" If the answers are no and no — add .lean() and .select(). This single habit, applied consistently, meaningfully improves the performance of any Mongoose application.
Pagination
Learn how to implement pagination in Mongoose using skip/limit and cursor-based pagination, with a reusable pagination helper.
Authentication
Learn the two layers of authentication in a MongoDB-backed app — database-level MongoDB authentication and application-level user authentication with hashed passwords and JWT.