Patterns for read performance: computed, subset, extended reference, bucket, outlier
The schema design patterns that turn expensive reads into cheap ones by doing work at write time or by storing data in the shape it is read.
- Pre-compute aggregates with the computed pattern
- Keep hot data small with the subset pattern
- Avoid joins with extended references
- Group time-ordered data with the bucket pattern
- Handle rare huge cases with the outlier pattern
conceptThe concept
| Pattern | Problem | Idea |
|---|---|---|
| Computed | Same aggregation on every read | Store the result (ratingAvg, reviewCount, totals) and update it on write or on a schedule |
| Subset | Large document, but screens need only part | Embed the most recent or most relevant N children; keep the rest in their own collection |
| Extended reference | Every read joins to fetch a few fields | Copy the few stable fields you need along with the reference (customer name and city into the order) |
| Bucket | Millions of tiny time-ordered documents | One document per entity per time window with an array of measurements and summary fields |
| Outlier | A few documents are far larger than the rest | Design for the common case; flag outliers (hasOverflow: true) and store their excess elsewhere |
| Approximation | High-frequency counters | Update the counter every N events or with sampling when exactness is not required |
whyWhy it matters
Most production applications read far more than they write (often 10:1 or 100:1). Spending a little at write time to make reads a single indexed lookup is usually the right trade. These patterns are the vocabulary architects use in design reviews and interviews.
Use caseReal-world scenario
The product page shows the average rating, number of reviews and the three most helpful reviews. Instead of aggregating 2,000 reviews per view, a background job (or a change stream consumer) maintains ratingAvg, reviewCount and topReviews on the product document: computed plus subset.
Try itExamples
Run and edit these in the interactive lesson.
db.reviews.aggregate([
{ $sort: { helpfulVotes: -1 } },
{ $group: {
_id: "$productId",
ratingAvg: { $avg: "$rating" },
reviewCount: { $sum: 1 },
topReviews: { $push: { title: "$title", rating: "$rating", votes: "$helpfulVotes" } } } },
{ $project: { ratingAvg: { $round: ["$ratingAvg", 2] }, reviewCount: 1, topReviews: { $slice: ["$topReviews", 3] } } },
{ $merge: { into: "products", on: "_id", whenMatched: "merge", whenNotMatched: "discard" } }
])
db.products.findOne({ _id: "P010" }, { name: 1, ratingAvg: 1, reviewCount: 1, topReviews: 1 })db.orders.findOne({}, { orderNo: 1, "items.productId": 1, "items.name": 1, "items.price": 1, shipping: 1 })db.events.aggregate([
{ $match: { customerId: { $lte: 20 } } },
{ $group: {
_id: { customerId: "$customerId", day: { $dateTrunc: { date: "$ts", unit: "day" } } },
count: { $sum: 1 },
maxLatency: { $max: "$latencyMs" },
events: { $push: { ts: "$ts", type: "$type" } } } },
{ $merge: { into: "lab_event_buckets", on: "_id", whenMatched: "replace" } }
])
db.lab_event_buckets.countDocuments()
db.lab_event_buckets.findOne({}, { count: 1, maxLatency: 1, events: { $slice: 2 } })db.lab_books.drop()
db.lab_books.insertMany([
{ _id: 1, title: "Niche title", buyers: [11, 12, 13] },
{ _id: 2, title: "Bestseller", buyers: [21, 22, 23], hasOverflow: true }
])
db.lab_books_overflow.insertOne({ bookId: 2, page: 1, buyers: [24, 25, 26, 27] })
db.lab_books.find({ hasOverflow: true }, { title: 1 })internalsUnder the hood
Fewer, smaller, denser documents mean more useful data per WiredTiger cache page and fewer index entries. The bucket pattern reduces index size dramatically: one index entry per bucket instead of per measurement. Time series collections (lesson below) apply bucketing automatically inside the server.
Avoid / preferDo this, not that
// on every profile view
db.orders.aggregate([
{ $match: { customerId: 66 } },
{ $group: { _id: null, spent: { $sum: "$total" } } }
])// once, when the order is placed
db.customers.updateOne({ _id: 66 },
{ $inc: { "stats.spent": 698.52, "stats.orders": 1 } })The computed pattern moves work from many reads to one write. Recomputing a lifetime total on every page view does the same work thousands of times.
db.products.updateOne({ _id: "P001" }, { $push: { reviews: review } }) // all of themdb.reviews.insertOne(review)
db.products.updateOne({ _id: "P001" }, { $push: { recentReviews: {
$each: [review], $sort: { createdAt: -1 }, $slice: 10 } } })The subset pattern keeps the 10 newest reviews on the product page document and every review in its own collection, so the hot document stays small.
PitfallsCommon mistakes
- Computing on every read what could be stored once.
- Embedding everything to "avoid joins" and creating huge documents; the subset pattern keeps only what is shown.
- Copying volatile fields in an extended reference (stock level, status) that then go stale.
- Forgetting to decide who updates the duplicated data and how often.
LimitsWhen not to use it
Every read pattern duplicates data. If the source changes often and every copy must be exactly right at all times, the duplication costs you extra writes and windows of staleness. The bucket and outlier patterns add code paths; skip them until volumes demand it.
SecuritySecurity notes
Extended references copy fields into more places. If you copy a customer's name and email into every order, an erasure request now has to find every copy. Copy the minimum, and prefer ids for personal data.
ChallengeTry it yourself
Build a subset on the fly: return product P001's name with only its 3 most recent reviews (rating and title).
Show one solution
db.products.aggregate([
{ $match: { _id: "P001" } },
{ $lookup: {
from: "reviews", localField: "_id", foreignField: "productId",
pipeline: [{ $sort: { createdAt: -1 } }, { $limit: 3 }, { $project: { _id: 0, rating: 1, title: 1 } }],
as: "recentReviews"
} },
{ $project: { name: 1, recentReviews: 1 } }
])InterviewQuestions you should be able to answer
Describe the subset pattern.
When a parent has many children but the screen shows only a few, embed a bounded subset (most recent, most helpful) in the parent for the fast path, and keep the full set in a separate collection for "see all" pages. It keeps the working set small and avoids a second query for the common view.
How do you keep computed fields correct?
Update them in the same write path (e.g. $inc reviewCount when inserting a review, in a transaction if both must be consistent), or asynchronously from change streams or a scheduled $merge job when eventual consistency is acceptable. Document the staleness tolerance.
What is the outlier pattern?
Design the schema for the typical document (e.g. a book with a few hundred buyers embedded) and handle the rare extreme case (a bestseller with millions) with a flag and overflow documents, instead of forcing every document into a design that only the outliers need.
QuizCheck your understanding
4 questions with instant feedback in the interactive lesson; a wrong answer goes into your review deck.