Mongoose lean() vs hydrated documents: choose read performance or full document features
Decide between Mongoose lean() and hydrated documents for read-heavy Node.js APIs, with trade-offs, a compact comparison, and a verification script.
13 Nov 2025, 20:43 UTC

Problem and Takeaway
Your Express API returns thousands of product documents per request, but response latency climbs and Node.js memory usage spikes under load. Mongoose's default find() returns fully hydrated documents: instances of your model with change tracking, getters, setters, virtuals, and middleware support. That feature set is unnecessary for read-only endpoints and adds per-document overhead. The useful takeaway: use lean() for read-only API responses to return plain JavaScript objects, and reserve hydrated documents for paths that mutate and save data or rely on document methods and virtuals.
Decision Guide and Constraints
Choose based on these constraints:
- Read-only versus read-write: lean() is for responses that are sent to the client without being saved back through Mongoose.
- Document features required: virtuals, instance methods, getters/setters, and pre/post hooks only run on hydrated documents.
- Payload size and memory: hydrated documents carry internal Mongoose state that increases JSON serialization size and memory pressure.
- Mongoose version: examples assume Mongoose 6.x or later; lean() behavior is stable across recent 6.x releases.
Supported Options Compared
| Option | What it returns | Read performance | Document features | Best for |
|---|---|---|---|---|
| Hydrated find() | Mongoose Document instances | Slower; hydration cost per document | Full: virtuals, methods, middleware, change tracking | Documents that will be modified and saved |
| lean() | Plain JavaScript objects | Faster; minimal per-document overhead | None by default; virtuals only with lean({ virtuals: true }) | Read-only API responses and exports |
| lean({ virtuals: true }) | Plain objects with virtuals applied | Slightly slower than plain lean(), still faster than hydration | Virtuals only; no methods or change tracking | Read-only responses that need computed virtual fields |
Trade-offs Explained
Hydrated documents give you the complete Mongoose object model. You can call doc.save(), access doc.isModified('name'), and trigger schema middleware. The cost is paid at query time: Mongoose instantiates a Document for every result, attaches internal state, and applies getters during serialization. On large result sets this increases CPU and memory usage and can delay the first byte of the HTTP response.
lean() tells Mongoose to skip hydration and return raw objects from the MongoDB driver. The query is still executed by MongoDB, but Mongoose does not wrap each document. This reduces per-row overhead and often improves throughput for read-heavy endpoints. The trade-off is that the result is not a Mongoose Document: doc.save() is unavailable, instance methods do not exist, and virtuals are excluded unless you pass { virtuals: true }. Changes made to a lean object cannot be persisted through Mongoose without re-fetching a hydrated document.
lean({ virtuals: true }) is a middle ground when a read-only response needs a computed field such as displayName or priceWithTax. It executes virtuals but still avoids full hydration, so you cannot save the result or run instance methods.
Concrete Implementation: Product Listing Endpoint
Run this in a Node.js script or Express route handler. The script requires a MongoDB user with read permission on the products collection.
// Assumes Mongoose 6.x and an existing MongoDB connection
const mongoose = require('mongoose');
const productSchema = new mongoose.Schema({
sku: { type: String, required: true, unique: true },
name: { type: String, required: true },
price: { type: Number, required: true },
category: { type: String, required: true },
}, { timestamps: true });
// Virtual used only by read responses
productSchema.virtual('priceLabel').get(function () {
return '$' + this.price.toFixed(2);
});
const Product = mongoose.model('Product', productSchema);
/**
* Fetch a page of products as plain objects.
* @param {string|null} category - optional filter placeholder
* @param {number} limit - page size placeholder
* @returns {Promise<{ items: Object[], nextCursor: string | null }>}
*/
async function listProductsLean(category = null, limit = 50) {
const query = Product.find(category ? { category } : {});
// lean() returns plain JS objects; no hydration overhead
const items = await query
.sort({ createdAt: -1 })
.limit(limit)
.lean()
.exec(); // required permission: find on products collection
const nextCursor = items.length ? items[items.length - 1]._id.toString() : null;
return { items, nextCursor };
}
/**
* Fetch a single product as a hydrated document when it will be updated.
*/
async function updateProductStock(productId, quantityDelta) {
// Hydrated document required for save() and middleware
const product = await Product.findById(productId).exec(); // read permission
if (!product) throw new Error('Product not found');
product.stock = (product.stock || 0) + quantityDelta; // assumes stock field exists
await product.save(); // write permission required
return product;
}
// Example usage (do not run against production without a read replica or staging copy)
// const { items } = await listProductsLean('electronics', 20);
Permissions and placeholders: run the script with a database user that has find permission on the target collection; updateProductStock additionally requires update permission. Replace Product with your model name and adjust field names. The category and limit placeholders represent request parameters from the API caller.
Verification Steps
- Insert 5,000 product documents into a staging collection using bulkWrite.
- Run
listProductsLeanand a hydrated equivalent (Product.find().sort().limit()withoutlean()) for the same 50-document page. - Measure end-to-end time with
console.time()around each call and compare heap usage withprocess.memoryUsage()before and after the query. - Confirm that lean results are plain objects:
Array.isArray(items) && items[0] instanceof mongoose.Document === false. - Confirm that hydrated results support save:
doc instanceof mongoose.Document === true. - Check schema validation independently: attempt to insert a product missing
name; Mongoose must reject it regardless of whether the read path uses lean().
Limitations and Practical Checks
- lean() strips Mongoose document behavior. Do not use it for documents you intend to modify and save through the same Mongoose instance.
- Virtuals are not included by default. Use
.lean({ virtuals: true })only when the virtual is safe to execute on raw data and does not depend on document state beyond the current fields. - Mongoose document middleware and instance methods do not apply to the returned plain objects.
- Returning plain objects directly to
res.json()is safe, but avoid mutating nested arrays expecting Mongoose array change tracking.
Practical Check
After deploying, compare the 95th percentile response time for the read endpoint before and after enabling lean(). If the endpoint never calls .save() or instance methods, lean() is the correct default; keep hydrated documents for the update path only.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.