References and populate

ref marks an ObjectId path as pointing at another model; populate swaps the id for the document. It is not a join: the server never sees one. Mongoose 243,355 runs a second query and stitches the results together in Node.js 2,131 , and that fact decides when to use it.

Counting the round trips populate really costsJavaScript
const Book = mongoose.model('Book', new Schema({
  title: String, authorId: { type: Schema.Types.ObjectId, ref: 'Author' } }));
// 200 books across 50 authors; every find and aggregate command is counted
await Book.find().populate('authorId').exec();
const books = await Book.find().exec();
for (const b of books) await b.populate('authorId');      // the tempting loop
await Book.aggregate([{ $lookup: { from: 'authors', localField: 'authorId',
  foreignField: '_id', as: 'author' } }, { $unwind: '$author' }]);
Output
populate():        2 server round trips, 23 ms
  second command: {"_id":{"$in":["6ab204d56b40adec31ab8dd2","6ab204d56b40adec31a ...
per-document loop: 201 server round trips, 156 ms
$lookup:           1 server round trips, 11 ms

One populate on a list of 200 costs two queries, not 201: Mongoose collects the distinct ids and issues a single $in. That is already the N+1 fix, and it is why populate on a page of results is usually fine. The 201-query version is what happens when you populate inside a loop — or, far more often, when a service populates one document at a time because each call looked cheap alone. Nearly seven times the wall clock here, on a local server; across a network it is the difference between a fast response and two seconds.

$lookup does the work on the server in one round trip and wins whenever the result is going straight out as JSON. populate wins when you want hydrated documents with their virtuals and methods, when the referenced documents live on another connection, or when the pipeline would become unreadable. Neither is free: both read the whole referenced document unless you pass select, as in .populate({ path: 'authorId', select: 'name country -_id' }). match filters the referenced documents, but after the parent query, so a book whose author fails the match comes back with authorId: null rather than being dropped. A missing reference yields null too, never an error, so the Bookshelf GET /api/v1/books/:id handler has to decide what a dangling authorId means.