lean() memory savings lost after adding populate() in Mongoose 6.x queries
0 reputation · 25 Apr 2022, 06:52 UTC
0 reputation · 25 Apr 2022, 06:52 UTC
I want to reduce heap usage when reading large collections in a Node.js service by using Mongoose's lean() query option, while still resolving referenced documents through populate() on a few fields.
The documentation states that lean() returns plain JavaScript objects instead of hydrated Mongoose documents, which removes per-document wrapper overhead. However, it is unclear to me how this interacts with populate(): if populated paths get re-wrapped as full documents, the memory benefit may be partially or fully negated. I am also unsure whether this behavior changed between Mongoose 5.x and 6.x/7.x, and whether older releases had query-cache behavior that retained wrapper objects across repeated lean queries.
The result sets involved are large enough that the driver buffering everything in memory is a concern, so cursor-based iteration or pagination may also be needed.
populate() on a lean() query return plain objects for populated paths in current Mongoose versions, or does it rehydrate them?lean() the documented way to avoid buffering the full result set?.lean() before .populate() yields plain JavaScript objects for both the root documents and every populated reference. The memory benefit of lean() is not lost.query.cursor() with .lean() is the documented streaming approach. For large result sets, Model.find().lean().cursor() (optionally with .populate()) returns a readable stream that yields one plain object at a time, avoiding full result-set buffering.When lean() is applied, Mongoose skips document hydration entirely—no getters, virtuals, change-tracking, or prototype methods are attached. populate() respects that flag: it fetches the referenced documents and merges them as plain objects into the same lean result. The populated fields are not re-wrapped as Mongoose documents.
const cursor = MyModel.find({ status: 'active' })
.lean()
.populate('owner', 'name email')
.cursor();
for await (const doc of cursor) {
// doc and doc.owner are plain objects
process(doc);
// do NOT accumulate in an array
}Key points:
.lean() before .populate() (order does not matter in current versions, but this is the conventional order)..cursor() instead of .exec() to get an async iterable.lean() strips getters, virtuals, and instance methods unless you pass { getters: true, virtuals: true }. If your populated paths rely on virtuals, they will be undefined.toObject() or toJSON() transforms on populated data, they will not run—plain objects are returned as-is.Are any of your populated paths using virtual populate or custom populate options (e.g., match, select, perDocumentLimit)? Those features work with lean, but if you depend on virtual getters on the populated documents, they will be absent unless you enable { virtuals: true } on the root lean() call.
Use comments to ask for clarification. Post a solution as an answer.
No question comments on this page.