MongoDB Associate Developer study materials
MongoDB Associate Developer study resources

Embedded reviews outgrow the product document

MongoDB Associate Developer · Put your knowledge to work in context.

All case studies

Embedded reviews outgrow the product document

A product catalog has 2.1 million products. Each product embeds its reviews in a reviews array: the average is 40 entries, but the 3,000 busiest products carry 2,000–6,000 reviews, and the largest document is 3.8 MB. Every product page shows the ten most recent reviews via $slice, and a nightly job writes an aggregate rating back to the product. Two symptoms: p99 product-page latency spikes whenever a popular item receives reviews, and the oplog is growing much faster than the write rate suggests. The team proposes moving reviews into their own collection and keeping a reviewCount field on the product.

1. Why do the popular products hurt read and write performance even though no document is near the 16 MB limit?

Read the worked answer

Three separate effects, none of which requires hitting 16 MB.

Writes: an embedded review update rewrites the whole document. MongoDB pads documents, but once the padding is exhausted the document must be relocated, and a 3.8 MB document that is updated often becomes write amplification - the storage engine rewrites megabytes to add a 300-byte review, and the oplog records the entire affected document or its delta for each change. That explains oplog growth exceeding the apparent write rate, and it also makes replication and checkpointing more expensive.

Reads: the product page only needs ten reviews, but if the query returns the document without a projection, the server reads and ships the full 3.8 MB. Even with $slice, the document must be read into memory to slice it. Cache pressure matters too: a handful of multi-megabyte documents evict far more useful pages from the WiredTiger cache, so unrelated queries slow down - which matches the "spikes when popular items are reviewed" symptom.

Also consider index entries: any multikey index over reviews has one entry per array element, so a 6,000-review document produces 6,000 index keys that must be updated on writes.

The proposed direction - separate collection plus a counter - is correct; the question is how to keep the page fast.

2. Design the model. How do you keep the product page a single fast read, and how do you keep `reviewCount` and the aggregate rating accurate?

Read the worked answer

Use the subset pattern rather than a pure split. Keep a bounded recentReviews array on the product (for example the newest 20, or the newest 20 plus the 5 highest-rated) written with $push: {$each: [...], $slice: -20} so the array has a hard ceiling. Product pages then serve from the product document alone - one read, small document, still $slice-able.

Store the full history in a reviews collection with {productId: 1, createdAt: -1} for pagination and {productId: 1, rating: -1} if you need top-rated. A review insert becomes two writes: insert the review document, then $inc/$push on the product.

On accuracy: $inc on a single product document is atomic, so reviewCount and ratingSum stay correct individually, but the pair of writes (review insert plus counter bump) is not atomic together. Options, in order of preference:

  1. Make the counter derived and self-healing: bump it optimistically and recompute it nightly with a $group over reviews. A drifted counter is cosmetic.
  2. If exactness is a hard requirement, wrap insert and counter update in a multi-document transaction - but that costs latency on the hot path, so only do it if a mismatch has real consequences (billing, for instance).
  3. Beware $inc on a field used in a frequently sorted index: it causes index churn. If you rank by review count, consider not indexing it.

Finally, bound the array growth with schema validation ($jsonSchema with maxItems on recentReviews) so a future code path cannot silently reintroduce the unbounded array.

3. A teammate argues that a single `reviews` collection with `$lookup` on every product page is simpler. What would you tell them, and when would you be wrong?

Read the worked answer

A $lookup per product page turns one indexed read into two round trips inside the server: a seek on reviews by {productId: 1, createdAt: -1} plus result assembly. With an index it is fast and predictable, and it avoids duplicated review text. But it costs more per page than reading a small embedded array, it makes the page vulnerable to reviews collection scan behaviour if the index is ever dropped or the field types are inconsistent, and it prevents serving the page from a covered or single-document read during a partial degradation.

The subset model deliberately duplicates up to 20 review documents. That duplication is the price for a bounded, self-contained product document and it is small compared with a 3.8 MB monster.

When would the pure $lookup design be the right call? When review text is large (photos, long-form), when products are almost always read with only a count, or when review content is edited and you cannot tolerate two copies drifting. Also, if the page is served from a cache layer (Atlas or an application cache) so the $lookup runs rarely, the extra cost is irrelevant and simplicity wins.

The judgement call: duplicate only bounded, read-hot, rarely-edited data. Unbounded and growing is the one thing you must not embed.