MongoDB · lesson 2 of 10
Intermediate
6- 2 Embedding vs Referencing: A 5.9MB Document and a 50x Fetch Time
- 3 explain() Proves It: 200,000 Docs Scanned vs. a Covered Query That Touches Zero
- 4 The Aggregation Stage-Order Bug: Wrong Results, No Error
- 5 When the Aggregation Optimizer Can (and Can't) Use Your Index
- 6 Change Streams: Watching a Collection in Real Time (Replica Set Required)
- 7 Multi-Document Transactions: When One Update Isn't Enough
Advanced
3Also part of MongoDB
1Embedding vs Referencing: A 5.9MB Document and a 50x Fetch Time
Embed reviews inside a book document and push 50,000 of them in — the document balloons to 5.9MB and fetching it goes from 2ms to 102ms. Moving to a referenced collection brings the fetch back to 3ms.
By SkillByExample · Published
The same relationship, modeled two ways, on the same real data — one costs 102ms to fetch, the other costs 3ms.
Embedding: fine when the array is bounded
docker exec mongo-lesson mongosh --quiet --eval '
db = db.getSiblingDB("bookshop");
db.books.insertOne({ title: "Dune", author: "Frank Herbert", tags: ["scifi", "classic", "epic"] });
printjson(db.books.findOne({ title: "Dune" }, { tags: 1, _id: 0 }));
'
{
tags: [
'scifi',
'classic',
'epic'
]
}
A handful of tags a human chooses is a genuinely good fit for embedding — bounded size, always needed alongside the book, no reason to pay for a second query.
The bug: embedding something unbounded
Reviews are different — anyone can leave one, indefinitely. Embed them the same way:
docker exec mongo-lesson mongosh --quiet --eval '
db = db.getSiblingDB("bookshop");
db.books.insertOne({ title: "Dune", author: "Frank Herbert", reviews: [] });
function sizeOf() {
return db.books.aggregate([{ $match: { title: "Dune" } }, { $project: { size: { $bsonSize: "$$ROOT" } } }]).toArray()[0].size;
}
print("size before:", sizeOf());
const bulk = [];
for (let i = 0; i < 50000; i++) {
bulk.push({ user: "user" + i, rating: (i % 5) + 1, comment: "Review number " + i + " with some extra text to pad it out a bit." });
}
db.books.updateOne({ title: "Dune" }, { $push: { reviews: { $each: bulk } } });
print("size after:", sizeOf());
'
size before: 420
size after: 5917110
50,000 real reviews (a plausible number for a genuinely popular book) grew one document from 420 bytes to nearly 5.9MB — over 14,000 times larger. MongoDB’s hard limit on a single document is 16MB; this one book is already more than a third of the way there, and it’s just one popular title in the catalog.
Measuring the actual cost
docker exec mongo-lesson mongosh --quiet --eval '
db = db.getSiblingDB("bookshop");
db.books.insertOne({ title: "Foundation", author: "Isaac Asimov", reviews: [] });
const t0 = Date.now();
db.books.findOne({ title: "Dune" });
const t1 = Date.now();
db.books.findOne({ title: "Foundation" });
const t2 = Date.now();
print("fetch bloated doc (ms):", t1 - t0);
print("fetch normal doc (ms):", t2 - t1);
'
fetch bloated doc (ms): 102
fetch normal doc (ms): 2
Same query shape, same container, back-to-back — 51x slower for the bloated document, even though nothing about the query asked for the reviews. MongoDB has to read and transfer the entire document for any query that returns it.
The fix: reference instead of embed
docker exec mongo-lesson mongosh --quiet --eval '
db = db.getSiblingDB("bookshop");
db.reviews.deleteMany({});
const bookId = db.books.findOne({ title: "Dune" })._id;
const bulk = [];
for (let i = 0; i < 50000; i++) {
bulk.push({ book_id: bookId, user: "user" + i, rating: (i % 5) + 1, comment: "Review number " + i + " with some extra text to pad it out a bit." });
}
db.reviews.insertMany(bulk);
db.reviews.createIndex({ book_id: 1 });
const t0 = Date.now();
const book = db.books.findOne({ title: "Dune" }, { title: 1, author: 1 });
const t1 = Date.now();
const reviewCount = db.reviews.countDocuments({ book_id: bookId });
const t2 = Date.now();
print("fetch book doc (ms):", t1 - t0);
print("count reviews separately (ms):", t2 - t1);
'
fetch book doc (ms): 3
count reviews separately (ms): 14
Same 50,000 reviews, moved into their own reviews collection with a book_id field and an
index on it. Fetching the book alone is back to 3ms — close to the original 2ms, because
the book document is small again. Counting the reviews is a separate, indexed, 14ms query —
two fast round trips beat one slow one.
Practice
1. Limit an embedded array's response size with a $slice projection instead of moving it out.
docker exec mongo-lesson mongosh --quiet --eval '
db = db.getSiblingDB("bookshop");
db.books.updateOne({ title: "Dune" }, { $set: { reviews: [{user:"a",rating:5},{user:"b",rating:4},{user:"c",rating:3},{user:"d",rating:5},{user:"e",rating:2}] } });
printjson(db.books.findOne({ title: "Dune" }, { reviews: { $slice: 2 }, _id: 0 }));
'
{
title: 'Dune',
author: 'Frank Herbert',
tags: [
'scifi',
'classic',
'epic'
],
reviews: [
{
user: 'a',
rating: 5
},
{
user: 'b',
rating: 4
}
]
}
$slice: 2 returns only the first two array elements in the response — useful for “show the
first page of reviews.” Note that mixing $slice into a projection without also explicitly
excluding other fields (with field: 0) leaves every other field in the result, which is why
title, author, and tags all came back alongside the sliced reviews. And the full
review array is still stored in and read from the document on disk during this query, so
$slice doesn’t fix the growth problem the earlier fetch-time measurement showed — it’s a
response-shaping tool, not a storage fix.
2. Decide, before writing any code: would you embed or reference a book's "categories" field, and why?
Embed. A book’s categories are chosen by an editor from a small, bounded set (fiction,
non-fiction, a handful of genre tags) — this is the “one-to-few” case the rule of thumb
describes, structurally identical to the tags example at the top of this lesson, not the
unbounded reviews case.
3. Count how many reviews ended up in the separate collection, confirming none were lost in the migration.
docker exec mongo-lesson mongosh --quiet --eval '
db = db.getSiblingDB("bookshop");
const bookId = db.books.findOne({ title: "Dune" })._id;
print(db.reviews.countDocuments({ book_id: bookId }));
'
50000
All 50,000 reviews made the move intact — the index on book_id is what keeps this count fast
rather than requiring a full collection scan.
Next: indexes — how explain() proves whether a query is actually using one.