Chapter 100 โ MongoDB Scenarios
๐ Definition
MongoDB scenario questions test whether you can design schemas, choose indexes, and diagnose database problems in a real application. The interviewer usually gives a feature or a symptom and expects you to reason about access patterns, document size, indexes, consistency and scale.
๐ง Two Rules That Answer Most Questions
- Model for your queries (access patterns), not for normalisation. Data that is read together should be stored together.
- Every frequent query needs an index that supports both its filter and its sort. Check with
explain("executionStats").
Scenario 1 โ "Design the schema for an e-commerce app: users, products, orders, reviews."
// users
{ _id, name, email /* unique */, passwordHash, role, addresses: [{ label, line1, city, pin }] } // few, bounded โ embed
// products
{ _id, name, slug /* unique */, price, category, stock, attributes: { color, size },
ratingAvg, ratingCount } // computed pattern: store aggregates
// orders โ SNAPSHOT product data at purchase time
{ _id, userId, status, createdAt,
items: [{ productId, name, price, qty }], // embed: price must not change later
shippingAddress: { line1, city, pin }, // copy, not a reference
total, payment: { provider, paymentId, status } }
// reviews โ separate collection (unbounded, paginated independently)
{ _id, productId, userId, rating, text, createdAt }Indexes: users.email unique; products.slug unique; products { category: 1, price: 1 }; orders { userId: 1, createdAt: -1 }; reviews { productId: 1, createdAt: -1 }; a unique { productId: 1, userId: 1 } on reviews (one review per user).
Why: order items are embedded and copied so history is accurate even if the product's price changes; reviews are referenced because they can grow without bound (16 MB document limit).
Scenario 2 โ "The orders page is slow for users with many orders."
Verify
db.orders.find({ userId: uid, status: "delivered" }).sort({ createdAt: -1 }).limit(20)
.explain("executionStats");
// Look at: winningPlan stage (COLLSCAN vs IXSCAN), SORT stage in memory,
// totalKeysExamined / totalDocsExamined vs nReturnedFix: a compound index following ESR โ Equality, Sort, Range:
db.orders.createIndex({ userId: 1, status: 1, createdAt: -1 });Also project only needed fields and use cursor-based pagination instead of large skip values.
Scenario 3 โ "Pagination gets slower the deeper you go (page 5,000 takes seconds)."
Cause: skip(n) still walks through n index entries/documents.
Fix: keyset (cursor) pagination on an indexed, unique sort key:
db.posts.find({ createdAt: { $lt: lastSeenCreatedAt } }).sort({ createdAt: -1 }).limit(20);
// with a tie-breaker on _id when timestamps can repeatTrade-off: no "jump to page 5,000" โ usually fine for feeds and infinite scroll. For admin tables, limit max page depth or use filters.
Scenario 4 โ "A document keeps growing and we hit the 16 MB limit (e.g. chat messages inside a room document)."
Cause: an unbounded array embedded in one document.
Fix: move messages to their own collection referencing the room ({ roomId, senderId, text, createdAt } with index { roomId: 1, createdAt: -1 }). If you need the latest messages with the room, use the subset pattern: keep the last 20 embedded, the rest in the collection. For time-series-like data, the bucket pattern (one document per room per hour/day) reduces document count.
Scenario 5 โ "We need a dashboard: revenue per day and top 5 products for the last 30 days."
const since = new Date(Date.now() - 30 * 86400_000);
db.orders.aggregate([
{ $match: { status: "paid", createdAt: { $gte: since } } }, // use an index; filter early
{
$facet: {
revenuePerDay: [
{ $group: { _id: { $dateTrunc: { date: "$createdAt", unit: "day", timezone: "Asia/Kolkata" } }, revenue: { $sum: "$total" }, orders: { $sum: 1 } } },
{ $sort: { _id: 1 } },
],
topProducts: [
{ $unwind: "$items" },
{ $group: { _id: "$items.productId", name: { $first: "$items.name" }, units: { $sum: "$items.qty" }, revenue: { $sum: { $multiply: ["$items.price", "$items.qty"] } } } },
{ $sort: { revenue: -1 } },
{ $limit: 5 },
],
},
},
]);At scale: precompute with a scheduled job into a daily_sales collection ($merge) or use a materialised view, so the dashboard reads a few small documents instead of scanning orders.
Scenario 6 โ "Transfer wallet balance between users safely."
Answer: use a multi-document transaction (replica set required) with a conditional update so the balance cannot go negative, plus a ledger entry. Retry on TransientTransactionError (session.withTransaction does this). Keep transactions short; do not call external APIs inside them.
Alternatively, design so a single-document atomic update is enough (e.g. store balance and recent transactions together) โ single-document operations are always atomic and much cheaper.
Scenario 7 โ "Product search with filters (category, price range, brand) and text search."
Options
- Small/medium catalogue: compound indexes for common filter combinations + a
textindex for keywords. - Better relevance, typo tolerance, facets, autocomplete: Atlas Search (Lucene-based
$searchstage) or Elasticsearch/OpenSearch.
db.products.createIndex({ category: 1, brand: 1, price: 1 });
db.products.find({ category: "shoes", brand: { $in: ["Nike", "Puma"] }, price: { $gte: 1000, $lte: 5000 } })
.sort({ price: 1 });Avoid unanchored $regex: /term/i on large collections โ it cannot use an index efficiently.
Scenario 8 โ "MongoDB CPU is at 100% after a release."
Verify: Atlas Performance Advisor / db.currentOp() / the profiler (db.setProfilingLevel(1, { slowms: 100 })) to find the new slow query shape; check explain.
Common causes: a new query without an index; a $lookup on an unindexed foreign field; $regex searches; a missing filter causing full scans; a cron job running a heavy aggregation at peak time.
Fix: add the index (build it in a rolling/background way on large collections), rewrite the query, schedule heavy jobs off-peak or on a secondary/analytics node, cache hot reads.
Scenario 9 โ "We deleted a field from the schema but old documents still have it; some documents have different shapes."
Answer: MongoDB is schema-flexible, so schema evolution must be planned:
- Add a
schemaVersionfield; code handles old and new versions (schema versioning pattern). - Migrate lazily (on read/write) or with a background script using
updateManyin batches. - Use Mongoose schemas and MongoDB
$jsonSchemavalidation to stop bad shapes being written. - Write migrations as code (e.g.
migrate-mongo) and run them in CI/CD.
Scenario 10 โ "Reads right after a write sometimes return old data."
Cause: reading from a secondary (read preference secondaryPreferred) that has not replicated the write yet; or a cache in front.
Fix: read from the primary for read-after-write flows; use causally consistent sessions; or writeConcern: "majority" + readConcern: "majority" where consistency matters. Keep secondary reads for analytics and tolerant views.
Scenario 11 โ "Old sessions/OTP/logs pile up and the collection keeps growing."
Fix: a TTL index โ MongoDB deletes expired documents automatically (background task about every 60 s).
db.otps.createIndex({ createdAt: 1 }, { expireAfterSeconds: 300 });
db.sessions.createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 }); // expire at the stored dateFor logs/metrics, consider time-series collections or archiving to cheaper storage (Atlas Online Archive, S3).
Scenario 12 โ "The collection has 2 billion documents. How would you scale?"
Answer outline
- First exhaust vertical options: indexes, archiving old data, better schema, bigger instance.
- Sharding: choose a shard key with high cardinality, even distribution, and that matches the main query (e.g.
{ tenantId: 1, _id: 1 }or a hashed key for even writes). Avoid monotonically increasing keys (timestamps, ObjectId) as a ranged shard key โ all inserts go to one shard. - Queries that include the shard key are routed to one shard (targeted); others scatter-gather across all shards.
- Changing a shard key later is possible (resharding) but expensive โ decide carefully.
Scenario 13 โ "populate() makes our API slow."
Causes: populating large arrays, nested populates, populating fields not needed, missing index on the referenced field (for match/virtual populate).
Fixes: select only needed fields (populate("author", "name avatar")), limit populated arrays, denormalise read-heavy small fields (store authorName alongside authorId โ extended reference pattern), or use a single $lookup aggregation with a pipeline and projection.
๐ฏ What Interviewers Are Scoring
- Embed vs reference decisions justified by access pattern, growth and update frequency.
- Index design using ESR and verified with
explain. - Awareness of limits (16 MB documents,
skipcost, unanchored regex) and consistency (atomic single-document updates, transactions, replica lag). - Knowing when to precompute instead of aggregating on every request.
โ Node & Express Scenarios | Index | Next: Production Incident Scenarios โ