Skip to main content
MongoDB · lesson 2 of 10
MongoDB intermediate Lesson 2 of 10

Embedding 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

mongodb schema-design data-modeling embedding performance

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.

Frequently Asked Questions

Isn't embedding supposed to be MongoDB's fast path, compared to a SQL join?
For a bounded, small relationship — a few tags, a handful of addresses — yes, embedding avoids a second round trip entirely. The tradeoff flips once the embedded array is unbounded: every read and write of the parent document now has to move the entire array too, even when only the parent's own fields are needed, which is exactly what turned a 2ms fetch into a 102ms one in this lesson.
What actually caused the fetch to slow down — the document's size on disk, or something else?
Both matter, but the immediate cost is that MongoDB has to read and transfer the entire document over the wire for any query that returns it, even a query asking only for the title and author. A 5.9MB document costs roughly proportionally more to move than a 420-byte one, regardless of how few fields the query actually projected.
How do I know if a one-to-many relationship is safe to embed?
The common rule of thumb is one-to-few (embed), one-to-many (usually reference), one-to-squillions (always reference) — a handful of tags or addresses is one-to-few; thousands of reviews, log entries, or time-series readings is one-to-squillions. If the array's growth is bounded by something a human chooses (a handful of tags they pick), embedding is safe; if it grows with usage (every user who ever leaves a review), it isn't.
Does referencing mean giving up MongoDB's fast reads entirely?
No — an index on the foreign-key-style field (book_id here) keeps the referenced-collection query fast, as shown by the 14ms count against 50,000 review documents. The two queries (fetch the book, count or list its reviews) become two fast round trips instead of one slow one, which is usually still faster in aggregate for anything beyond the bounded case.
Can I limit how much of an embedded array comes back, instead of switching to referencing?
Yes, with a $slice projection — db.books.findOne({...}, { reviews: { $slice: 2 } }) returns only the first two array elements. This helps the response size for one particular query, but the full array is still stored on and read from disk as part of the document during that query, so it doesn't fix the underlying growth problem the way moving to a separate collection does.