Everything for MongoDB in one place — pick a section below. 17 reviewed items across 4 content types.
Quick Answer
Once the active working set no longer fits in the WiredTiger cache (by default about 50% of RAM minus 1GB), MongoDB has to evict pages and fetch data back from disk on subsequent access, which shows up as rising page fault rates and increasing read latency even on queries that are already using the right index. This is distinct from a missing-index problem because explain() will still show a clean IXSCAN with a good docsExamined ratio — the slowdown is coming from storage I/O, not the query plan.
Detailed Answer
Picture a chef working in a kitchen with a small countertop (memory) and a much larger pantry down the hall (disk). As long as all the ingredients for today's menu fit on the counter, everything is fast — grab and go. The moment the menu needs more ingredients than the counter can hold, the chef has to keep walking to the pantry and back mid-recipe, and every dish takes longer, even though the chef is following the exact same recipe (query plan) as before.
MongoDB's default storage engine, WiredTiger, keeps a cache of frequently accessed data and indexes in memory, sized by default to roughly 50% of total RAM minus 1GB (with a floor of 256MB), specifically because leaving the rest of RAM for the OS file system cache and other processes was found to perform better than giving WiredTiger everything. This design assumes the 'hot' working set — the data actually being read and written by current traffic — fits inside that cache; MongoDB was never designed to require your entire dataset to fit in RAM, only the actively used portion of it.
When the working set exceeds the cache, WiredTiger has to evict pages (remove cached data to make room for new reads) using an LRU-like (least recently used) policy, and any subsequent access to evicted data triggers a page fault requiring a read from disk. This happens even for a query that is using a perfectly appropriate index, because the index itself is also subject to caching — a large index that doesn't fit in cache means even the index lookup incurs disk reads, not just the document fetch. This is the key diagnostic distinction from a query-plan problem: explain() still reports IXSCAN with a tight docsExamined-to-nReturned ratio, because the query planner did its job correctly; the slowdown is purely a storage-layer cache-miss cost that explain() doesn't directly surface.
In production, engineers watch wiredTiger.cache statistics from db.serverStatus() — specifically "bytes currently in the cache" against the configured cache size, and "pages read into cache" as a rate — alongside OS-level disk read IOPS and page fault counts. A cluster whose cache hit ratio (tracked in db.serverStatus().wiredTiger.cache) is dropping while query plans remain unchanged is a strong signal of a memory-sizing problem rather than an indexing problem, and the fix is either scaling to a larger instance with more RAM, sharding to spread the working set across more nodes, or reducing the working set itself (e.g., archiving cold data out of the hot collection).
The gotcha that catches teams sizing their clusters: adding more indexes to 'fix' slow queries can make this problem worse, not better, because every index also competes for the same WiredTiger cache space as the actual document data. A collection with five large secondary indexes might have a working set that technically includes documents plus all five indexes, and if that combined footprint exceeds the cache, you can end up in a state where adding the index that was supposed to speed up one query pushes another previously-fast query's data out of cache, trading one latency problem for a different one that shows up on a completely unrelated endpoint.
Code Example
# Check current WiredTiger cache size and usage
db.serverStatus().wiredTiger.cache
# Look specifically at pages read into cache (a proxy for cache-miss rate)
db.serverStatus().wiredTiger.cache["pages read into cache"]
# Confirm a slow query's plan is actually clean (rules out a missing-index cause)
db.orders.find({ status: "completed" }).explain("executionStats")
# If cache-bound, consider trimming the working set by archiving cold data out
db.orders.deleteMany({ status: "completed", created_at: { $lt: ISODate("2025-01-01") } })
# (after confirming it has been archived to cold storage first)Interview Tip
A junior engineer typically responds to any MongoDB slowness by reaching for 'add an index.' For a senior or architect role, the interviewer wants you to recognize that a clean, well-indexed query plan can still be slow purely because the working set has outgrown the WiredTiger cache, and to know the specific serverStatus metrics (cache size, pages read into cache) that distinguish a storage-layer problem from a query-plan problem. The deeper insight they're listening for is that indexes themselves consume cache space, so blindly adding indexes to chase query speed can shrink the effective cache available for actual document data and create a new latency problem somewhere else in the same cluster.
◈ Architecture Diagram
┌───────────────┐ fits ┌────────────┐ │ Working set │─────────▶│ WT cache │ fast (RAM) └───────────────┘ └────────────┘ ┌───────────────┐ exceeds ┌────────────┐ ┌───────┐ │ Working set │─────────▶│ WT cache │──▶│ Disk │ slow, page faults │ (+ 5 indexes) │ │ (evicting) │ └───────┘ └───────────────┘ └────────────┘
💬 Comments
Quick Answer
This is almost always a shard key with poor cardinality or a monotonically increasing value (like a timestamp or auto-incrementing id), which routes most new writes to the same chunk range and therefore the same shard regardless of how many shards exist. The balancer can only move whole chunks between shards, so it can't fix ongoing imbalance caused by every new document hashing or ranging into the same hot chunk — the real fix is choosing a better shard key or switching to hashed sharding, not just waiting for the balancer.
Detailed Answer
Imagine a hospital intake system where every new patient is assigned to a bed strictly by arrival timestamp, always filling ward A first, no matter how many other wards are open and empty. No amount of moving already-assigned patients between wards afterward (the balancer) fixes the fact that every single new patient keeps arriving into the same ward, because the assignment rule itself, not the redistribution process, is what's broken.
MongoDB shards a collection by splitting its documents into contiguous chunks based on a shard key's value, and distributes those chunks across shards; the balancer's job is only to migrate existing chunks to even out chunk count, not to change how new documents get assigned to chunks in the first place. If the shard key is monotonically increasing — an ObjectId (which embeds a timestamp) or an auto-incrementing counter — every new document's key value is always higher than every previous one, so it always lands in the single chunk currently representing the 'highest' range, which lives on one shard at a time. This was a known, deliberate tradeoff in range-based sharding: it makes range queries efficient (because a range of values sits contiguously on one shard) at the direct cost of write distribution when the key increases monotonically.
Internally, MongoDB does split an overfull chunk into two once it crosses a size threshold, and the balancer does eventually migrate one of those split chunks to a less-loaded shard — but by the time that migration completes, all of the new incoming writes have already moved on to an even newer chunk, which is still hashing or ranging onto the same original 'hot' shard. The balancer is fixing yesterday's imbalance while today's writes recreate a new one, so the hot shard's disk usage keeps climbing net of whatever the balancer manages to move away.
In production, engineers monitor per-shard disk usage, chunk count per shard, and specifically watch for a shard whose chunk count is roughly proportional to the others but whose disk usage or operation rate is disproportionately higher — that pattern (balanced chunk count, imbalanced load) is the signature of a hot chunk problem rather than a raw chunk-distribution problem, and it means adding more shards won't help until the key itself changes. The standard production fix is either switching the shard key to a hashed shard key (MongoDB hashes the key value before assigning it to a chunk range, spreading monotonic values pseudo-randomly across shards) or choosing a compound shard key that combines a coarser, evenly-distributed field with the monotonic one.
The gotcha that catches teams doing this migration: you cannot change a collection's shard key in place in most MongoDB versions — fixing this requires creating a new, correctly-sharded collection and migrating data into it (or using MongoDB 5.0+'s reshardCollection, which does this online but still consumes significant IO and time proportional to collection size), so 'switch the shard key' is not a quick config change, it's closer to a full data migration that has to be planned with the same care as any other zero-downtime resharding project, including a cutover step for in-flight writes during the migration window.
Code Example
# Check chunk distribution per shard
sh.status()
# Check per-shard disk usage and operation counts
db.adminCommand({ listShards: 1 })
db.serverStatus().shardCursorType
# Confirm the shard key pattern currently in use (look for monotonic fields like _id or created_at)
db.orders.getShardDistribution()
# Reshard online to a hashed key on MongoDB 5.0+ (long-running, plan for a maintenance window)
db.adminCommand({
reshardCollection: "payments.orders",
key: { customer_id: "hashed" }
})Interview Tip
A junior engineer typically assumes sharding automatically balances load and blames the balancer when one shard fills up. For a senior or architect role, the interviewer wants you to trace the imbalance back to the shard key design itself — specifically monotonically increasing keys creating a permanently 'hot' chunk that the balancer can never keep up with because new writes keep recreating the imbalance faster than migrations can fix old instances of it. They're listening for whether you know the balancer only redistributes existing chunks and cannot change future write routing, and that the real fix (hashed or compound shard key) requires a full resharding operation, not a configuration tweak — which has real operational cost and needs to be planned, not applied reactively during an active capacity incident.
◈ Architecture Diagram
Monotonic key (_id, created_at) ┌────────┐ ┌────────┐ ┌────────┐ │Shard A │ │Shard B │ │Shard C │ │ 90% ↑ │ │ 30% │ │ 30% │ all NEW writes → Shard A └────────┘ └────────┘ └────────┘ Hashed key ┌────────┐ ┌────────┐ ┌────────┐ │Shard A │ │Shard B │ │Shard C │ │ 50% │ │ 50% │ │ 50% │ writes spread evenly └────────┘ └────────┘ └────────┘
💬 Comments
Quick Answer
Leading indicators are oplog window size shrinking, replication lag trending upward even slightly, and rising WiredTiger cache eviction rate, because they show the replica set losing headroom before any read or write actually fails. Lagging indicators — read timeouts on secondary reads, rollback events, or election storms — mean impact has already reached the application, so mature alerting pages on the leading trio well before those symptoms appear.
Detailed Answer
Think of the oplog like a ship's rolling log book that only has room for the last 200 pages — once full, the oldest page is torn out to make room for the newest entry. If a crew member (a secondary node) falls behind reading the log and the ship keeps tearing out old pages, at some point the crew member arrives at a page that's already been destroyed, and they have no way to catch up incrementally anymore — they need an entirely fresh copy of the ship's current state instead.
MongoDB's oplog (operations log) is a capped collection — a fixed-size collection that overwrites its oldest entries once full — that every secondary reads from to replicate changes from the primary. The oplog window is the amount of time, in minutes or hours, that currently sits in the oplog before the oldest entry gets overwritten; it is a moving target that shrinks under high write volume and grows during quiet periods. This design exists because keeping an unbounded log of every write forever would consume unbounded disk, so MongoDB trades log completeness for a rolling window sized to comfortably outlast normal replication lag under expected load.
Replication lag itself — the difference between the primary's latest operation timestamp and a given secondary's last applied operation timestamp — is a lagging measurement in the sense that by the time it's nonzero, replication is already behind, but it's a leading indicator relative to the real failure mode: if replication lag ever exceeds the current oplog window, the lagging secondary can no longer resume from where it left off, because the entries it needs have already been overwritten. At that point, that secondary requires a full initial sync (copying the entire dataset fresh from another member), which can take hours on a large collection and puts additional load on the source node it's syncing from during that time.
In production, mature monitoring tracks all three signals together: oplog window size (in minutes of retained history), replication lag (in seconds), and the ratio between them — because a replica set with a 24-hour oplog window and 30 seconds of lag has enormous headroom, while the same 30 seconds of lag against a 5-minute oplog window is one bad batch job away from a required resync. WiredTiger cache eviction rate is a related leading indicator, since a secondary whose cache is thrashing (evicting and re-reading pages repeatedly) applies oplog entries slower, which is often the actual root cause behind lag that otherwise looks like 'network is slow.'
The gotcha that catches teams sizing their oplog: oplog window shrinks non-linearly during predictable spike events — a nightly bulk import job, a Black Friday traffic surge, a backfill script — and a window that comfortably covers 24 hours of normal traffic can shrink to under 30 minutes during a two-hour bulk write job, and if any secondary was already a few minutes behind going into that window, the bulk job itself can be the direct cause of that secondary needing a full resync, turning a routine batch job into an unplanned, hours-long, high-IO resync event on the exact day the cluster is already under the most load.
Code Example
# Check current oplog window size in hours db.getReplicationInfo() # Check replication lag per secondary from the primary's perspective rs.printSecondaryReplicationInfo() # Check WiredTiger cache eviction rate on a lagging secondary db.serverStatus().wiredTiger.cache["pages evicted by application threads"] # Alert threshold logic (pseudocode for PromQL/Datadog): page when # replication_lag_seconds > (oplog_window_minutes * 60 * 0.25)
Interview Tip
A junior engineer typically says 'monitor replication lag' and stops there. For a senior or architect role, the interviewer wants you to connect replication lag to oplog window size as a ratio, not an absolute number, because the same lag value means very different things depending on how much runway the oplog provides before a secondary needs a full resync. They're also listening for whether you anticipate that predictable high-write events (bulk imports, seasonal traffic) shrink the oplog window non-linearly and can turn a secondary that was already slightly behind into one requiring a multi-hour initial sync — turning a planned batch job into an unplanned capacity incident on your busiest day.
◈ Architecture Diagram
Oplog window (rolling, capped)
┌────────────────────────────┐
│ oldest ←───────────▶ newest │
└────────────────────────────┘
Secondary lag ──────────▶│ still inside window: OK ✓
Secondary lag ─────────────────────────────────────▶✗
(past oldest entry: needs full resync)💬 Comments
Quick Answer
Automate backups using a point-in-time-consistent method (like filesystem snapshots coordinated across all shards plus oplog replay, or mongodump only for small collections) and make the restore path idempotent and non-destructive by always restoring into a freshly named, isolated target before ever touching production data. The critical safety property isn't backup speed, it's guaranteeing the backup is transactionally consistent across every shard as of a single point in time, since a naive per-shard backup taken at slightly different moments can restore a cluster into a state that never actually existed.
Detailed Answer
Picture a bank trying to photograph every teller window in a large branch to create a single 'state of the vault' record. If each photo is taken a few seconds apart instead of simultaneously, and money is moving between windows during that gap, the resulting set of photos can show a transaction's withdrawal at one window but not yet its matching deposit at another — a snapshot that looks internally consistent per-photo but describes a moment in time that never actually existed across the whole branch.
In a sharded MongoDB cluster, data lives across multiple independent shards, each potentially its own replica set, and a single logical transaction or a cross-shard read can touch several of them. A backup taken by running mongodump or a filesystem snapshot against each shard independently, at slightly different wall-clock moments, produces a set of per-shard backups that are each locally consistent but collectively describe a cluster state that's inconsistent — some shards reflect a slightly later or earlier moment than others. MongoDB's cluster-wide backup tooling (like MongoDB Atlas's cluster snapshots, or Percona Backup for MongoDB) was built specifically to solve this by coordinating a single logical timestamp across all shards and the config servers, ensuring every shard's snapshot represents the exact same moment.
The correct automated approach captures three things atomically: a snapshot or dump of every shard's data, a snapshot of the config servers (which track which chunks live on which shard — restoring shard data without matching config server state can point queries at the wrong shard for a given key range), and enough oplog history around the snapshot point to allow point-in-time recovery to an exact moment rather than only to the last full snapshot. For non-sharded replica sets, this reduces to coordinating one consistent snapshot per replica set member instead of per shard, but the same core requirement — one consistent point in time — still applies.
In production, restore automation should never restore directly over the live production cluster as its first action; the disciplined pattern is to always restore into a freshly provisioned, isolated cluster first, run automated integrity and row-count validation checks against it, and only then proceed to the actual cutover (which itself should be a separate, explicit, approved step — often involving a brief write freeze and a DNS or connection-string switch, not an in-place overwrite). Engineers track restore drill frequency and restore time objective (RTO) as operational metrics, not just backup success rate, because a backup that has never been test-restored is not a verified backup, it's an unverified file.
The gotcha most teams learn only during an actual incident: restoring a sharded cluster's data without also restoring the config server metadata to the exact matching point in time can succeed technically — the restore command reports success and the cluster comes up — while silently returning wrong or incomplete query results, because the config servers' chunk-to-shard mapping no longer matches where the data actually landed. This kind of failure doesn't throw an error; it just quietly returns fewer documents than expected for range queries that now believe certain chunk ranges live on shards that no longer have that data, which can go unnoticed for days until someone notices a report total looks slightly off.
Code Example
# Take a coordinated, cluster-wide consistent snapshot (conceptual sequence for a sharded cluster)
# 1. Stop the balancer to prevent chunk migrations mid-backup
sh.stopBalancer()
# 2. Snapshot config servers and every shard's replica set at the same logical timestamp
# (using cloud provider volume snapshots or Percona Backup for MongoDB's coordinated snapshot)
pbm backup --config /etc/pbm/pbm-config.yaml
# 3. Restart the balancer once all snapshots are confirmed complete
sh.startBalancer()
# 4. Restore into a freshly named, isolated cluster first — never restore over production directly
pbm restore 2026-07-05T03:00:00Z --config /etc/pbm-restore/pbm-config-staging.yaml
# 5. Run row-count and checksum validation against the restored cluster before any cutover
mongo --host restore-cluster:27017 --eval 'db.orders.countDocuments({})'Interview Tip
A junior engineer typically describes backup automation as 'run mongodump on a cron schedule.' For a senior or architect role, the interviewer wants you to recognize that in a sharded cluster, backup correctness depends on cross-shard point-in-time consistency, not just per-shard success, and that config server metadata has to be restored in lockstep with shard data or queries silently return wrong results with no error raised. They're also listening for whether your restore process defaults to restoring into an isolated environment for validation first, rather than treating 'restore' and 'cutover to production' as the same step — conflating those two is one of the most common causes of a botched disaster-recovery drill turning into a second, self-inflicted outage.
◈ Architecture Diagram
Shard A snapshot @ t1 ┐ Shard B snapshot @ t2 │ inconsistent if t1 != t2 != t3 ✗ Config svr snapshot t3 ┘ Coordinated snapshot @ same t for all ✓ ▶ restore into ISOLATED cluster ▶ validate ▶ cutover
💬 Comments
Quick Answer
Embed data that's always read together with its parent and has a genuinely enforced cap on growth; reference data that grows unboundedly or needs independent access patterns, since every MongoDB document has a hard 16MB size ceiling and unbounded embedded arrays degrade every write on that document long before they hit it. The real failure mode isn't picking embed vs reference wrong on day one — it's an array assumed bounded (order line items) quietly becoming unbounded (a decade of subscription events) as the product evolves.
Detailed Answer
Think of embedding like stapling a customer's entire receipt history into one folder, versus referencing like keeping a customer file that just lists ticket numbers pointing to receipts stored in a separate filing cabinet. A stapled folder is fast to grab because everything is in one place. But if that customer generates thousands of receipts — a subscription billing daily for ten years — the folder becomes unmanageably thick, slows down every clerk who lifts it, and eventually can't fit in the drawer at all.
MongoDB stores related data as a single BSON document instead of joining across tables at query time like a relational database, and it enforces a hard 16MB per-document size limit (BSON is the binary JSON-like format MongoDB stores documents in) specifically to keep documents fitting comfortably in memory and network payloads. Embedding was designed for the common case where an application always reads a parent with its children in one request — an order and its line items — because a single document read is one disk seek and one round trip, versus a reference model's multiple round trips or a $lookup join. Referencing exists for the opposite case: data accessed independently of its parent, or that would make the parent balloon without bound.
When you embed, MongoDB stores the child data as a nested array inside the parent's BSON document. Every write to that document — even one touching a single embedded item — requires the storage engine to read and rewrite the relevant parts of the whole document, so a document with a 50,000-element array pays a growing tax on every write, not just reads. Referencing instead stores just an ObjectId (MongoDB's unique document identifier) pointing to another collection, and the application or an aggregation $lookup stage does a second query to join them; this keeps each document small and its write cost constant regardless of how many children exist, at the cost of an extra index and round trip.
In production, teams use the bucket pattern for genuinely high-write, time-growing data like IoT readings or activity events: instead of one document per device with an ever-growing array, they create a new document every N items or every time window (one document per device per hour), each with a bounded embedded array. This caps document size while keeping writes append-only. Engineers monitor average and p99 document size via db.collection.stats(), watch for documents approaching the 16MB ceiling well before they hit it, and alert on write latency creeping up on documents with large embedded arrays, since that's usually the earliest symptom of a design that's aging badly.
The gotcha that bites teams who designed correctly on day one: 'bounded' arrays are often only bounded by current product usage, not by anything structural. An order.line_items array is fine at 5-20 items until a bulk-ordering feature ships and some orders have 3,000 items, or a user.notifications array grows forever because nobody added a cleanup job. The array being unbounded doesn't throw an error until the exact moment a document crosses 16MB, so the failure is invisible in code review and in most of production, then appears suddenly as a hard write rejection on exactly the customer with the most data — usually your biggest account.
Code Example
# Insert an order with a bounded, embedded line-items array (few items, always read together)
db.orders.insertOne({
customer_id: ObjectId("64f1a2b3c4d5e6f7a8b9c0d1"),
items: [{ sku: "CHK-9001", qty: 2, price: 14.50 }],
total: 29.00
})
# Reference pattern: store only an id, keep the growing event history in its own collection
db.checkout_events.insertOne({
order_id: ObjectId("64f1a2b3c4d5e6f7a8b9c0d1"),
event: "payment_captured",
ts: new Date()
})
# Bucket pattern: one document per device per hour caps embedded array growth
db.device_readings.updateOne(
{ device_id: "press-line-04", hour: ISODate("2026-07-05T09:00:00Z") },
{ $push: { readings: { ts: new Date(), psi: 812 } } },
{ upsert: true }
)
# Check document size distribution before it becomes a 16MB emergency
db.orders.aggregate([{ $project: { size: { $bsonSize: "$$ROOT" } } }, { $sort: { size: -1 } }, { $limit: 5 }])Interview Tip
A junior engineer typically answers this as a static rule — 'embed for one-to-few, reference for one-to-many.' For a senior or architect role, the interviewer is actually testing whether you think about schema design as something that decays over time as product usage changes, not a decision made once. They want to hear you describe the bucket pattern as the real production answer for anything that grows with time, explain why MongoDB's 16MB limit makes unbounded embedding a silent, deferred failure rather than an immediate one, and describe how you'd monitor document size proactively (db.collection.stats(), $bsonSize in aggregation) rather than discovering the problem when a write gets rejected in production against your largest customer.
◈ Architecture Diagram
Embed (bounded) Reference (unbounded)
┌──────────────┐ ┌─────────┐ ┌──────────┐
│ order │ │ order │──▶│ events │
│ items[2] │ │ (id only)│ │ (grows) │
│ total │ └─────────┘ └──────────┘
└──────────────┘
✓ 1 read ✓ write cost stays flat💬 Comments
Quick Answer
An IXSCAN (index scan) only proves an index was used, not that it was used efficiently — a query can scan thousands of index entries and still discard most of them if the index doesn't match the query's equality, sort, and range fields in the right order. The fix is almost always rebuilding the compound index using the ESR rule (Equality fields first, then Sort fields, then Range fields) so the index itself does more of the filtering.
Detailed Answer
Picture a phone book sorted by last name. If you search for everyone with last name 'Chen' born after 1990, using the phone book (the index) is obviously faster than reading every entry in the city (a collection scan). But if the phone book is instead sorted by birth year first, you'd have to flip through every single page checking for 'Chen' on each one — you're still using the book, just not efficiently, because the index's sort order doesn't match how you're actually searching.
MongoDB's explain("executionStats") reports a stage called IXSCAN when the query planner chose to walk an index rather than scan the whole collection (COLLSCAN). Teams often stop there, assuming 'IXSCAN means it's fixed,' but the real signal for efficiency is the ratio between totalDocsExamined (how many documents the index scan pulled up as candidates) and nReturned (how many of those actually matched the full query and were returned). A healthy index keeps that ratio close to 1:1; a ratio of 500:1 means the index narrowed the search barely at all before MongoDB had to fetch and manually filter hundreds of documents per result.
This mismatch happens because compound indexes (indexes on multiple fields) are ordered structures — MongoDB can only use an index's sort order left-to-right, meaningfully filtering on the leading fields and merely following the rest as a means to a sort or avoid a separate sort pass. This is why the ESR rule exists: put Equality-match fields first (so MongoDB jumps straight to the matching range), then Sort fields (so results already come out in the needed order without a separate in-memory sort), then Range fields last (since range conditions can only narrow, not jump to, a position in the index). An index built in the wrong order — say, range field first — forces MongoDB to scan a huge swath of the index checking each entry individually.
In production, engineers watch db.currentOp() and the slow query log (via profiling level 1 or 2) for exactly this totalDocsExamined-to-nReturned gap, not just the presence of COLLSCAN, because dashboards that only alert on 'any collection scan' miss the more common production issue of a technically-indexed but poorly-ordered query silently burning CPU and disk IO under real traffic. Index selectivity (how much a field narrows the result set) also matters — an index on a boolean 'is_active' field barely helps if 95% of documents share the same value, since the planner still has to examine a huge fraction of the index.
The gotcha that catches experienced engineers too: MongoDB's query planner caches the winning query plan and reuses it for subsequent similar queries, so a plan that was efficient when a collection was small can keep getting reused even after data distribution changes dramatically (for example, once 90% of orders become status: "completed" instead of an even split) — until the plan cache is invalidated by a write threshold or a manual db.collection.getPlanCache().clear(), the query keeps running the now-inefficient plan without any error, just quietly worse latency.
Code Example
# Run explain to see IXSCAN vs COLLSCAN and the docsExamined-to-nReturned ratio
db.orders.find({ status: "pending", region: "us-east" }).sort({ created_at: -1 }).explain("executionStats")
# Build a compound index following the ESR rule: Equality, Sort, Range
db.orders.createIndex({ status: 1, region: 1, created_at: -1 })
# Confirm the new plan actually improved the examined-to-returned ratio
db.orders.find({ status: "pending", region: "us-east" }).sort({ created_at: -1 }).explain("executionStats")
# Clear a stale cached query plan if data distribution has shifted significantly
db.orders.getPlanCache().clear()Interview Tip
A junior engineer typically stops the moment explain() shows IXSCAN, treating it as proof the query is fast. For a senior role, the interviewer wants you to dig into totalDocsExamined versus nReturned as the real efficiency signal, and to apply the ESR rule (Equality, Sort, Range) to explain why a compound index's field order matters as much as its existence. Bonus depth: mentioning that MongoDB caches query plans and can keep running a stale, now-inefficient plan after the underlying data distribution shifts, which is a production-only failure mode that never shows up in a dev environment with small, evenly distributed test data.
◈ Architecture Diagram
Bad order: {created_at:-1, status:1} Good order (ESR): {status:1, region:1, created_at:-1}
IXSCAN → examines 50,000 entries IXSCAN → examines ~200 entries
nReturned: 40 (ratio 1250:1) ✗ nReturned: 40 (ratio 5:1) ✓💬 Comments
Quick Answer
A write acknowledged with the default or w:1 write concern only confirms the primary accepted it, not that any secondary replicated it yet — if the primary crashes before that replication happens, the write is lost the moment a secondary without it gets elected as the new primary, and the old primary's un-replicated data is rolled back when it rejoins. Using w:majority prevents this because it only acknowledges a write once enough nodes have it that no election can produce a primary without it.
Detailed Answer
Imagine a manager who approves an expense reimbursement the instant they read the request, before the finance team has actually recorded it in the ledger. If the manager gets hit by a bus that afternoon and someone else takes over with only the finance team's ledger to go on, the approval that only existed in the manager's head is simply gone — the new manager has no way to know it ever happened, and there's no record to roll back because there was never a shared record in the first place.
MongoDB replica sets have one primary node accepting all writes and multiple secondaries replicating from it asynchronously via the oplog (operations log, a capped collection recording every write for secondaries to replay). Write concern controls how many nodes must confirm a write before MongoDB reports success back to the application: w:1 (the historical default in some driver configurations) means only the primary needs to have it in memory; w:majority means a majority of voting members must have replicated it. This tiered design exists because forcing every write to wait for full replication would hurt latency, so MongoDB lets applications choose their durability-versus-speed tradeoff per operation.
When a primary fails, the remaining secondaries detect the loss via missed heartbeats and hold an election, using the same core idea as other consensus protocols: the candidate whose oplog is most caught-up (compared via each secondary's last applied operation) has the best chance of winning, and a majority of voting members must agree. If the old primary had accepted a w:1 write that no secondary had replicated yet, that write simply never existed anywhere except the now-dead primary's local state — the newly elected primary has no way to know about it, and the cluster moves forward without it.
In production, this becomes actively destructive when the old primary rejoins the replica set after recovering: MongoDB runs a rollback procedure that finds where the rejoining node's oplog diverges from the new primary's history and discards the divergent writes from the old primary, writing them to a rollback file on disk rather than replaying them, specifically to keep all replica set members consistent. Engineers monitor replication lag (how far behind secondaries are), oplog window (how much time of history the oplog currently holds before it starts overwriting itself), and election frequency, because a replica set with growing lag is both more likely to lose more data on a failover and more likely to hit rollback conflicts.
The gotcha many teams learn only after an incident: switching every write to w:majority does eliminate this class of data loss, but it does not eliminate the underlying election pause — writes are still blocked for the 2-12 second window while a new primary is elected, and application code that isn't using retryable writes (a driver feature that automatically retries a write once against the new primary) will see hard errors during that window rather than a graceful delay, which looks identical to a data-loss bug from the application's error logs until you correlate the timestamps against the replica set's election log.
Code Example
# Check current replica set state and confirm which node is primary
rs.status()
# Insert an order with majority write concern so it survives a failover
db.orders.insertOne(
{ customer_id: "cust_88213", total: 129.00, status: "placed" },
{ writeConcern: { w: "majority", wtimeout: 5000 } }
)
# Enable retryable writes at the driver/connection-string level
# mongodb://host1,host2,host3/payments?replicaSet=rs0&retryWrites=true&w=majority
# Check oplog window size to estimate how much history is available before overwrite
db.getReplicationInfo()Interview Tip
A junior engineer typically answers 'w:majority is safer' without explaining the actual mechanism of what fails without it. For a senior or architect role, the interviewer wants you to walk through exactly why an acknowledged w:1 write can vanish — because acknowledgment and replication are decoupled by design — and to describe the rollback file mechanism that discards the old primary's divergent writes when it rejoins. They're also listening for whether you understand that w:majority fixes data loss but not availability during the election window, and that retryable writes are the piece that actually smooths over that window for the application rather than surfacing hard errors to users.
◈ Architecture Diagram
Primary (w:1 write, not yet replicated) ✗ crashes
│
↓
Secondary A (no such write) ── elected new Primary
│
Old Primary rejoins ──▶ rollback: divergent write
written to rollback file, discarded💬 Comments
Quick Answer
The usual cause is putting an expensive stage like $group or $sort before a $match that could have filtered out most documents first, forcing MongoDB to process the full collection in memory before it ever narrows the data down. The fix is reordering the pipeline so $match runs first and can use an index, projecting away unneeded fields early with $project, and reserving allowDiskUse as a safety net rather than a substitute for good stage ordering.
Detailed Answer
Think of an aggregation pipeline like an assembly line sorting mail. If you make every single piece of mail in the country pass through the 'sort by zip code' station before you even filter out mail that isn't headed to your region, you've wasted enormous effort sorting things you were going to throw away anyway. The efficient approach is to filter out everything irrelevant at the very first station, so every downstream station only ever touches the mail that actually matters.
MongoDB's aggregation pipeline processes documents through an ordered sequence of stages, and only the first stage in the pipeline can use a collection index efficiently — after that, documents are already in an in-memory working set being transformed stage by stage. Each individual stage (other than a handful of exceptions) is also subject to a 100MB memory limit; if a single $group or $sort stage needs to hold more than 100MB of intermediate data in RAM, MongoDB throws an error unless allowDiskUse is enabled, which lets that stage spill to temporary files on disk. The pipeline model was designed this way so each stage can be reasoned about independently, but that same modularity means a badly-ordered pipeline pays the full cost of every stage on the full dataset rather than a shrinking one.
The practical failure pattern: a pipeline that starts with $group (aggregating totals across an entire multi-million-document collection) before ever applying a $match filtering down to, say, the last 24 hours of orders, forces that $group stage to build accumulator state across every document in the collection. If that intermediate state exceeds 100MB — which happens quickly when grouping by a high-cardinality field like customer_id across millions of orders — the stage fails outright in strict mode, or silently spills to slow disk-based temp files if allowDiskUse is on, either way at far higher cost than if $match had run first and used an index to narrow the working set from millions of documents to a few thousand.
In production, teams profile pipelines using db.collection.explain() on the aggregate call (not just find queries) to see whether early stages report IXSCAN and how many documents survive each stage, and they add a $project immediately after the initial $match to drop unneeded fields before expensive stages like $group or $sort run, shrinking the per-document memory footprint carried through the rest of the pipeline. allowDiskUse is treated as an emergency valve for pipelines that must legitimately process large intermediate result sets (like a full monthly reporting rollup), not a default flag to slap on every pipeline to paper over bad ordering, because disk-spilled stages run dramatically slower under real traffic than the same pipeline properly filtered upfront.
The gotcha that surprises even experienced engineers: only the very first stage of a pipeline can use an index, and only if it's a $match (or certain $sort placements) with no preceding transformation — inserting even a harmless-looking $addFields before your $match silently disables the index for that $match, since MongoDB no longer sees an untouched collection scan target at that point in the pipeline. Staging environments with small seeded datasets never surface this because the 100MB limit and index-usage difference are invisible at low document counts, which is exactly why this class of bug reliably passes code review and staging tests, then fails only under production data volume.
Code Example
# Bad ordering: $group before $match forces full-collection scan into memory
db.orders.aggregate([
{ $group: { _id: "$customer_id", total: { $sum: "$amount" } } },
{ $match: { total: { $gt: 1000 } } }
])
# Fixed ordering: $match first (uses index), $project drops unneeded fields early
db.orders.aggregate([
{ $match: { status: "completed", created_at: { $gte: ISODate("2026-07-01") } } },
{ $project: { customer_id: 1, amount: 1 } },
{ $group: { _id: "$customer_id", total: { $sum: "$amount" } } },
{ $match: { total: { $gt: 1000 } } },
{ $sort: { total: -1 } },
{ $limit: 100 }
])
# Confirm the first stage is actually using the index, not a collection scan
db.orders.explain("executionStats").aggregate([
{ $match: { status: "completed" } }
])Interview Tip
A junior engineer typically fixes aggregation memory errors by adding { allowDiskUse: true } and moving on. For a senior or architect role, the interviewer wants you to diagnose why the pipeline needed that much memory in the first place — usually a $match placed too late, or a stage inserted before $match that silently disqualifies it from using an index. They're listening for whether you know only the pipeline's first stage can use an index, that $project should drop fields early to shrink the working set, and that allowDiskUse is a safety net for legitimately large reporting queries, not a substitute for correct stage ordering — because disk-spilled stages carry a real, measurable latency cost under production load that never shows up against a small staging dataset.
◈ Architecture Diagram
Bad: $group(all rows) → $match ✗ full collection in RAM Good: $match(indexed) → $project → $group → $sort → $limit ✓ narrowed early
💬 Comments
Context
E-commerce with 2M products in 15 normalized tables needed flexible attributes per category.
Problem
Adding attributes required ALTER TABLE, downtime, and updating all queries. Hundreds of nullable columns.
Solution
Migrated to MongoDB flexible document model. Each product embeds category-specific attributes. Used schema validation for required fields.
Commands
db.products.createIndex({ category: 1, price: 1, available: 1 })Outcome
New attributes ship in hours. Zero-downtime changes. Query performance improved 3x.
Lessons Learned
Design schema around query patterns, not normalization.
💬 Comments
Context
A SaaS whose single MongoDB replica set served both the product (latency-sensitive CRUD) and a growing analytics workload (BI dashboards, nightly exports, ad-hoc aggregations) — with analysts' $group pipelines regularly evicting the product's working set from WiredTiger cache.
Problem
Every heavy aggregation caused product p99 spikes (cache pollution + CPU contention on the secondaries serving reads); one analyst's unindexed match had triggered a paging incident; and the 'solution' of asking analysts to be careful was working as well as it ever does.
Solution
Segregated the workloads on one replica set: added a dedicated analytics node configured with priority 0, hidden (never eligible for primary, invisible to normal read preference), and tagged {role: analytics}; BI tools and export jobs connect with read preference targeting the tag, while product traffic keeps primary/secondaryPreferred on the visible members. The analytics node got a larger cache and its own alerting thresholds (its cache pressure is expected). Ad-hoc query governance rode along: analysts get a connection string that can only reach the hidden member, plus maxTimeMS defaults.
Commands
rs.reconfig: {_id: 4, host: 'analytics-1', priority: 0, hidden: true, tags: {role: 'analytics'}}BI conn string: readPreference=secondary&readPreferenceTags=role:analytics
driver: cursor.maxTimeMS(60000) default for the analytics user
Outcome
Product p99 stabilized (cache pollution ended — the working sets no longer compete); analytics got faster too (a cache sized for their access pattern); the paging-incident class disappeared; and the same replica set serves both, no ETL pipeline built. Replication lag on the analytics member during nightly exports is visible and harmless by design.
Lessons Learned
hidden+priority:0+tags is the underused trio — most teams jump to a separate cluster (cost, sync complexity) before exhausting topology tools. The analytics member's lag must be excluded from the product's lag alerting, or it pages for its own job description.
💬 Comments
Context
A multi-tenant events collection sharded years ago on {tenant_id: 1} — reasonable until three whale tenants grew to 60% of traffic, producing jumbo chunks that the balancer couldn't split and permanent hot shards no hardware upgrade could fix.
Problem
The shard key's cardinality ceiling (one tenant = one chunk floor) made rebalancing impossible; the classical fix — dump, recreate with a new key, reload — meant a maintenance window the 24/7 product couldn't take; and query routing was degrading as scatter-gather crept up.
Solution
Used reshardCollection (MongoDB 5.0+) to change the key online to {tenant_id: 1, event_month: 1, _id: 1} — compound key preserving tenant-locality for routing while adding cardinality for splitting: the resharding operation cloned data to recipient shards under the new key while tailing oplog changes, with the final cutover blocking writes for only the last catch-up seconds. Preparation was most of the project: query audit ensuring the dominant patterns include tenant_id (routed, not scatter-gather), capacity headroom on all shards for the temporary double-storage, and off-peak scheduling with abort criteria (resharding is abortable until commit).
Commands
db.adminCommand({reshardCollection: 'app.events', key: {tenant_id: 1, event_month: 1, _id: 1}})monitor: db.currentOp filter on reshardCollection — remainingOperationTimeEstimatedSecs, oplog application lag
abort path (rehearsed): db.adminCommand({abortReshardCollection: 'app.events'})Outcome
Resharding completed in 26 hours of background work with a write-block measured in single-digit seconds; chunk distribution evened out within a day of balancer work (whale tenants now split across many chunks by month); the hot shard's CPU dropped 55%; and routed-query percentage returned to target. No maintenance window, no dump-reload.
Lessons Learned
The temporary 2x storage requirement is the real constraint — one shard was upgraded just to host the operation. Compound keys that keep the old prefix preserve routing for existing queries; changing the prefix entirely would have been a different (much riskier) project.
💬 Comments
Symptom
Primary stepped down unexpectedly. "not primary" errors spiked. Writes failed for 8 seconds.
Error Message
MongoServerError: not primary
Root Cause
Primary ran out of disk space on oplog partition. Oplog could not grow so primary stepped down.
Diagnosis Steps
Solution
Freed disk space, increased oplog size, switched to w:majority with retryable writes. Added disk monitoring alerts.
Commands
rs.status()
df -h /data/db
Prevention
Monitor disk space on all members. Size oplog proportional to write volume.
💬 Comments
Symptom
Minutes after a deploy that added a TTL index (expireAfterSeconds on created_at), the primary's write latency spikes 20x, replication lag climbs on both secondaries, and the application's p99 goes red. Disk I/O is saturated with deletes; normal writes queue behind them.
Error Message
No error — mongod is doing exactly what it was told. currentOp shows the TTLMonitor's delete storm; serverStatus deletes/sec at four orders of magnitude above baseline; oplog window shrinking from 48h toward 3h as deletes flood it.
Root Cause
The collection had years of accumulated documents that were all instantly expired under the new TTL — the TTL monitor began deleting the entire backlog at full speed. Each delete is a write: it consumes I/O, holds tickets, and lands in the oplog (shrinking the effective oplog window and pressuring secondaries). Nobody had sized the initial purge; the index looked like metadata but behaved like the largest write burst in the system's history.
Diagnosis Steps
Solution
Emergency: disabled the TTL monitor (setParameter ttlMonitorEnabled: false) to restore service, then drained the backlog controllably — a batched background job deleting in rate-limited chunks during off-peak windows over four nights (with oplog window monitoring as the throttle governor). Re-enabled the TTL monitor once the backlog reached steady-state, where it handles only the daily increment invisibly.
Commands
db.adminCommand({setParameter: 1, ttlMonitorEnabled: false})db.events.countDocuments({created_at: {$lt: new Date(Date.now() - 90*864e5)}})drain: bulk deleteMany in {_id: {$lt: batchBoundary}} chunks, sleep between, watch rs.printReplicationInfo()Prevention
TTL indexes on existing collections are a data migration wearing an index's costume: estimate the initially-expired count first (countDocuments below the cutoff), pre-purge in controlled batches, and only then create the index. Monitor oplog window and deletes/sec as standing alerts. The same trap applies to raising expireAfterSeconds — lowering it re-creates the surge.
💬 Comments
Symptom
During a traffic surge, the replica set fails over — then fails over again minutes later, and again: three elections in twenty minutes, each dropping in-flight writes and stampeding client reconnections. Each new primary lasts only until it, too, saturates.
Error Message
Logs: 'Member rs0-2 is now in state PRIMARY' cycling across members; drivers: 'not primary' write errors in bursts; heartbeat timeout messages preceding each stepdown — the members are alive but too slow to answer heartbeats within electionTimeoutMillis under load.
Root Cause
The workload surge (a new feature's unindexed query pattern multiplying COLLSCANs) saturated the primary's CPU/ticket capacity to the point where replica-set heartbeats — which share the same node resources — timed out. Peers declared the primary unavailable and elected a new one, transferring the same untenable workload to a cold-cache member that saturated even faster. The cluster mistook overload for failure and 'healed' itself into a rolling degradation: election storms are overload amplifiers, not remedies.
Diagnosis Steps
Solution
Broke the cycle by shedding load, not by touching the topology: enabled maxTimeMS enforcement on the offending query family via the driver config rollback (the feature flag reverted), which restored primary headroom; heartbeats stabilized and elections stopped. The query got its index the same day. Follow-ups: election timeout raised modestly (10s -> 15s) to tolerate brief saturation without topology churn, and 'elections per hour' became a paging alert wired to a runbook that starts with 'find the overload' rather than 'check the network'.
Commands
rs.status() history + grep 'election' /var/log/mongodb/mongod.log
db.currentOp({active: true, secs_running: {$gt: 5}}) — the COLLSCAN familydb.serverStatus().wiredTiger.concurrentTransactions # ticket exhaustion
Prevention
Distinguish dead from drowning: election storms during load events almost always mean saturation, and the fix is shedding (kill queries, revert features, rate-limit) — failover just moves the fire. Keep query governance guardrails (maxTimeMS defaults, index-review for new query shapes) so one feature can't saturate the primary, and alert on election frequency as an overload symptom.
💬 Comments