This page is for developers facing a MongoDB round, from a first backend job to a senior data role. Most rounds open with documents, BSON and ObjectId, then spend real time on schema design: when to embed, when to reference, and how data grows. Next come indexes and explain, a query or aggregation you write live, and then replica sets, sharding, write concern and transactions for more senior roles. Each question shows what the interviewer is checking, the shape of a strong answer and a short answer you can say out loud. Practise saying them, then swap in stories from your own projects.
Search all questions by round, difficulty and level, or save the ones you want to practise.
Document: one record made of field and value pairs, which can nest documents and arrays.
Collection: a group of documents, like a table, but the documents need not share fields.
BSON: a binary form of JSON with extra types (dates, 64-bit integers, decimals, ObjectId, binary) and size prefixes so fields can be skipped quickly.
"A document is one record, stored as field and value pairs, much like a JSON object. Values can be nested documents or arrays, so one order can carry its line items inside it. A collection is a group of documents, roughly like a table, except the documents don't have to share the same fields unless I add validation. Under the hood MongoDB stores documents as BSON, which is a binary form of JSON. There are two reasons. First, types: JSON only has strings, numbers, booleans, arrays, objects and null, but BSON adds dates, 32 and 64-bit integers, decimals, ObjectIds and raw binary. Second, speed: BSON records the size of documents, arrays and strings up front, so the server can skip over them without parsing text. One limit worth knowing is that a single document can't be larger than 16 megabytes."
Saying MongoDB stores raw JSON text, or that documents in a collection must all have the same fields.
Structure: 12 bytes: a 4-byte timestamp in seconds, a 5-byte random value per process, a 3-byte counter.
Where made: usually by the driver on the client, so no round trip to the server is needed.
Ordering: roughly by creation time, only to the second, and not exact across machines.
"An ObjectId is the default type for _id, and it's 12 bytes. The first four bytes are a timestamp in seconds since the Unix epoch. Then there are five random bytes generated once per process, and the last three bytes are a counter that starts at a random value. The driver normally creates it on the client, which is why an insert doesn't need to ask the server for an id first. Because the timestamp comes first, sorting by _id gives you roughly the order documents were created, and I can read the time back with getTimestamp. But I wouldn't rely on it for exact order. It only has one-second precision, and two app servers with slightly different clocks can produce ids out of order. When the order really matters, I store a proper createdAt date and index it."
Claiming ObjectIds are generated by the server in strict global order, or that they are random and carry no time information.
Good fit: data read as a whole object, fields that vary between records, schema that changes often, large write volume that needs to scale out.
Weaker fit: many-to-many data queried from many angles, heavy ad-hoc joins and reporting, rules best enforced by foreign keys.
Deciding factor: how the data is read and written, not whether the database has transactions.
"I think about how the data is read. MongoDB fits well when the thing I load is naturally one object: a product with attributes that differ by category, a user profile with settings, an article with its tags, or event data coming in at high volume. Documents map straight to the objects in my code, I can add fields without a migration, and sharding lets me scale writes across machines. I'd lean relational when the data is a web of relationships that people query from many angles, like an accounting ledger or an inventory system where reports join five tables in ways nobody predicted, and where foreign keys and constraints are doing real work. It isn't about transactions any more, since MongoDB has multi-document transactions. It's about whether my queries line up with how I can shape the documents."
Saying MongoDB is always faster, or that it cannot do transactions, or choosing it only because the schema is "flexible".
Embed when: the data is read together, belongs to one parent, and stays small and bounded.
Reference when: the data is shared, read on its own, updated often by itself, or can grow without limit.
Middle ground: keep a reference but copy a few fields you always show, and accept updating those copies.
"I start from the queries. If I almost always read two things together and the child only belongs to one parent, I embed. An order's line items or a user's shipping addresses are good examples: there are a few of them, they're owned by the parent, and one read gets everything. I reference when the related data is shared by many parents, is read on its own, changes independently a lot, or can grow without a limit. Comments on a popular post or orders for a customer can run into the thousands, so they go in their own collection with an indexed field pointing back. Often I do a bit of both: I keep the author's id on a post but also copy the author's name, because that's what the page shows. The cost is that renaming an author means updating those copies, which is fine if it's rare."
Normalising everything as if it were a relational schema, or embedding everything including data that grows without limit.
Problems: the 16 megabyte document limit, every read loading the whole array, and huge index entries if the array is indexed.
Separate collection: one document per item with an indexed reference back to the parent.
Subset or bucket: keep only the latest few embedded, or group items into bucket documents of fixed size.
"The hard limit is that a document can't pass 16 megabytes, so an array that grows forever will eventually break inserts. But it hurts long before that. Every time I read the parent, I pull the whole array into memory and over the network, even if the page shows five items. Updates get heavier as the document grows. And if the array is indexed, each element gets its own index entry, so the index balloons. The usual fix is to move the items into their own collection, one document each, with the parent's id and a date, and a compound index on those two fields. If the page always shows the newest few, I also keep a small embedded copy, using push with the slice option so it never holds more than, say, ten. For high-volume data like readings, I use buckets: one document per parent per hour, holding a fixed number of items."
db.posts.updateOne(
{ _id: postId },
{ $push: { recentComments: { $each: [comment], $slice: -10 } },
$inc: { commentCount: 1 } }
)
Not knowing about the document size limit, or suggesting a bigger server as the fix.
Server side: a JSON Schema validator on the collection, with required fields, types and allowed values.
Rollout controls: validation action (error or warn) and validation level (strict or moderate).
App side: a schema layer such as Mongoose, plus a version field for documents that change shape.
"Flexible schema doesn't mean no schema. The shape just lives somewhere you choose. My first line is a validator on the collection itself, written in JSON Schema: I list required fields, their BSON types and allowed values, and the server rejects any insert or update that breaks the rules, no matter which app or script sent it. There are two useful knobs. The validation action can be error, which rejects the write, or warn, which lets it through and logs it, and I use warn when I'm adding rules to messy existing data. The validation level can be strict, or moderate, which skips checks on updates to documents that were already invalid. On top of that, the app usually has its own schema, like a Mongoose model, and for collections that evolve I add a schemaVersion field so code knows which shape it's reading."
db.createCollection("orders", {
validator: { $jsonSchema: {
bsonType: "object",
required: ["customerId", "total", "status"],
properties: {
total: { bsonType: "decimal" },
status: { enum: ["pending", "paid", "shipped"] }
}
} },
validationAction: "error"
})
Saying there is no way to enforce structure in MongoDB, or relying only on app code when several services write to the same collection.
Queries first: list the pages: a post with its comments, an author page, a tag listing, the home feed.
Collections: authors, posts with a copied author summary and a tags array, comments in their own collection.
Indexes: a unique slug, author plus date, tag plus date, and post plus date on comments.
Trade-offs: copied author names need updating on rename; recent comments embedded for the first paint.
"I'd start by listing the pages: open a post, list posts by an author, list posts by tag, and the home feed by date. Then I'd have three collections. Authors hold the profile. Posts hold the title, body, a unique slug, a publishedAt date, a tags array, and a small author summary with the id, name and avatar, because every post page shows those. Comments go in their own collection with the postId and a date, because a popular post can get thousands. On the post I'd keep a comment count and maybe the latest three comments so the first view needs one read. For indexes: unique on slug, author id plus publishedAt descending for the author page, tags plus publishedAt for tag pages, which becomes a multikey index, and postId plus createdAt on comments. The trade-off is that renaming an author means updating their posts, which I'd do in a background job."
Embedding every comment inside the post with no limit, or designing collections without saying which queries they serve.
Problem: one document per reading means a huge document count and very large indexes.
Buckets: group readings per sensor per time window, with summary fields precomputed.
Built-in option: a time series collection, which buckets internally, plus automatic expiry for retention.
Scale: shard on sensor plus time, never on time alone.
"One document per reading works at first, but at this rate the document count and the indexes grow fast, and the indexes stop fitting in memory. The classic fix is the bucket pattern: one document per sensor per hour, holding an array of readings plus summary fields like count, min, max and sum that I update as readings arrive. Charts that only need hourly numbers never touch the raw values. Newer MongoDB versions give me that without hand-rolling it: a time series collection, where I name the time field and a meta field like the sensor id, and the server stores readings in compressed buckets internally. I'd set an expiry so raw data older than my retention window drops off, and roll older data into summaries if the business needs history. If it outgrows one replica set, I'd shard on sensor id plus time. Time alone would send every new write to the same shard."
db.createCollection("readings", {
timeseries: { timeField: "ts", metaField: "sensor", granularity: "seconds" },
expireAfterSeconds: 60 * 60 * 24 * 30
})
db.readings.insertOne({ ts: new Date(), sensor: { id: "s-104", site: "plant-2" }, temp: 21.4 })
Storing each reading as a separate document with no plan for size, or sharding on the timestamp alone.
Code first: release code that handles the field missing, and writes it on every new document.
Backfill: update old documents in small batches, throttled, watching replication lag.
Enforce last: add a validator, in warn mode first, then error, once the backfill is complete.
Safety: make each step reversible and the backfill restartable.
"I'd do it in steps so nothing breaks at any point. First, ship code that reads the field with a sensible default when it's missing, and writes it on every new or updated document. At this point the app works with both shapes. Second, backfill the old documents. I wouldn't run one giant updateMany, because tens of millions of changes flood the oplog and can push secondaries far behind. Instead I'd walk the collection in _id order, updating a few thousand documents per batch where the field doesn't exist, with a pause between batches and a check on replication lag. Because the filter only matches documents still missing the field, I can stop and restart safely. Third, once a count shows none are missing, I add a validator requiring the field, first in warn mode to catch any writer I forgot, then in error mode. Only then does the code treat the field as required."
One huge update run in business hours, or adding a strict validator before old documents and all writers are ready.
Filter: fields in one filter document are combined with AND; use range operators for age and an in-list for cities.
Projection: include name and email, and turn _id off explicitly because it is returned by default.
Extras: sort and limit so the result is predictable and bounded.
"I'd call find on the users collection with two documents. The first is the filter. Fields listed together are combined with AND, so I put status equal to active, then age with greater-than-or-equal 18 and less-than-or-equal 30 in the same sub-document, which gives an inclusive range. For the cities I use the in operator with an array of the three names, which matches any of them. The second document is the projection: name one and email one. The _id field comes back unless I say otherwise, so I set it to zero. I'd also add a sort on name and a limit so the result is predictable and can't return the whole collection by accident. If this runs often, a compound index on status, city and age would serve the filter, and I'd check the plan with explain."
db.users.find(
{
status: "active",
age: { $gte: 18, $lte: 30 },
city: { $in: ["Austin", "Lisbon", "Manila"] }
},
{ name: 1, email: 1, _id: 0 }
).sort({ name: 1 }).limit(50)
Writing age twice as two separate keys in the filter, where the second silently overwrites the first.
Field changes: set writes a field; inc adds to a number and creates it if missing.
Arrays: push appends every time; addToSet appends only if that exact value is not already there.
No operator: updateOne rejects it; replaceOne swaps the whole document, keeping only _id.
"Set writes a value to a field, adding it if it doesn't exist, and leaves every other field alone. Inc adds a number to a field, or creates it with that number, and because a single-document update is atomic, two requests incrementing a counter at the same moment both count. Push appends to an array every time, so it can add duplicates. AddToSet only appends if the exact value isn't already in the array, which is handy for tags or a list of user ids who liked something. If I pass a plain document with no operators to updateOne, the modern drivers and the shell reject it, because an update must say what to change. If I really want to swap the whole document, that's replaceOne, and everything except _id is replaced. The older update method used to do that silently, which is how people wiped fields by accident."
db.posts.updateOne(
{ _id: postId },
{
$inc: { views: 1 },
$set: { lastViewedAt: new Date() },
$addToSet: { tags: "databases" }
}
)
Reading the document, changing it in app code and writing it back, which loses concurrent changes, when an operator would do it atomically.
Skip and limit: simple, but the server still walks past every skipped entry, so a deep page costs far more than page one.
Range paging: remember the last item's sort values and ask for items after them, served straight from an index.
Tie-breaker: sort on a unique pair, such as createdAt plus _id, so no item is skipped or shown twice.
Trade-off: no jumping straight to page 37, which an infinite scroll does not need anyway.
"Skip and limit is the first thing people write, and it's fine for the first few pages. The problem is that skip isn't free. To skip fifty thousand documents the server still walks past fifty thousand index entries, so deep pages get slower and slower. It can also show duplicates or miss items when new posts arrive between page loads, because everything shifts. For a feed I use range-based paging, sometimes called keyset or cursor paging. I sort by createdAt descending with _id as a tie-breaker, since two posts can share a timestamp. The client sends back the createdAt and _id of the last item it saw, and I ask for items older than that, with a limit. With an index on createdAt and _id, every page costs about the same no matter how deep. The trade-off is that you can't jump straight to page 37, but an infinite scroll never needs to."
db.posts.createIndex({ createdAt: -1, _id: -1 })
// next page, given the last item the client saw
db.posts.find({
$or: [
{ createdAt: { $lt: last.createdAt } },
{ createdAt: last.createdAt, _id: { $lt: last._id } }
]
}).sort({ createdAt: -1, _id: -1 }).limit(20)
Saying skip is free because an index exists, or paging on a sort key that is not unique so items repeat or go missing.
Without: a collection scan reads every document to find matches.
With: a sorted B-tree of field values points to documents, so the server jumps to the matching range.
Cost: each index takes disk and memory and slows every insert, update and delete a little.
"Without a usable index, MongoDB does a collection scan: it reads every document and checks it against the filter. On a small collection that's fine, but on millions of documents it's slow and pushes useful data out of memory. An index is a sorted structure, a B-tree, holding the values of the indexed fields and a pointer to each document. With it, the server walks straight to the range of keys that match, then fetches only those documents. In explain output that's the difference between a COLLSCAN stage and an IXSCAN followed by a FETCH. Every collection gets a unique index on _id automatically. Indexes aren't free, though. Each one uses disk and memory, and every write has to update every index on the collection, so I only add the ones my real queries need."
Suggesting you index every field, or not knowing that indexes slow down writes.
Rule of thumb: Equality fields first, then Sort fields, then Range fields.
Why sort before range: after the equality match, entries are already in date order, so no in-memory sort is needed.
Prefixes: an index on a, b, c also serves queries on a, and on a and b, but not on b alone.
Verify: check with explain; a very selective range can justify a different order.
"I'd use the equality, sort, range rule, so status first, then the date, then price. Here's why. Status is an exact match, so the server jumps to one contiguous block of the index. Inside that block, entries are ordered by date, so the results come out already sorted and the server can stop as soon as it has enough for the limit. Price goes last, and the server checks it while walking the keys. If I put price before the date, the entries for different prices would interleave their dates, and MongoDB would have to sort the results in memory, which is slow and has a memory cap. I also remember the prefix rule: this index serves queries on status alone, and status with date, but not date alone. It's a rule of thumb, not a law. If the price range is tiny and very selective, I'd compare both orders with explain."
db.products.createIndex({ status: 1, createdAt: -1, price: 1 })
db.products.find({ status: "live", price: { $gte: 10, $lte: 50 } })
.sort({ createdAt: -1 })
.limit(20)
Ordering fields by how selective they seem without thinking about the sort, or not knowing the prefix rule.
What it is: an index on an array field gets one entry per element; MongoDB marks it multikey automatically.
Use: fast queries like "posts tagged databases" on a tags array.
Limit: in a compound index, each document can have at most one indexed field that holds an array.
"When I index a field that holds an array, MongoDB creates one index entry for every element, and flags the index as multikey on its own. I don't declare it. That's what makes a query like find posts where tags equals databases fast on a tags array. Two things to watch. First, size: a document with fifty tags adds fifty entries, so long arrays make the index big and writes heavier. Second, the compound limit: in a compound multikey index, any one document can have at most one of the indexed fields as an array. If I index tags and categories together and a document has arrays in both, the insert fails, and if such documents already exist, the index build fails. So I'd either index them separately or rethink the model. Multikey indexes also can't be a shard key."
Thinking you must declare an index as multikey, or not knowing the one-array-per-document rule for compound indexes.
TTL index: an index on a date field with an expiry in seconds; a background task deletes expired documents.
Per-document time: set expiry to zero and store the exact expiry date in each document.
Catches: deletion is not instant, and documents without a real date in that field never expire.
"I'd use a TTL index. I create an index on a date field, like createdAt, with an expiry in seconds, say one hour for sessions, and a background task on the server removes documents once that time has passed. If different documents need different lifetimes, I set the expiry to zero and store an expireAt date in each document, so each one leaves at its own time. There are catches I always mention. The cleanup task runs about once a minute, so a document can hang around a little after it expires, which means my login check should still compare the date itself and never trust existence alone. The field has to hold a real BSON date. A string that looks like a date, or a missing field, means the document never expires. And on a replica set, the deletes happen on the primary and replicate like any other write."
db.sessions.createIndex({ createdAt: 1 }, { expireAfterSeconds: 3600 })
db.otps.createIndex({ expireAt: 1 }, { expireAfterSeconds: 0 })
db.otps.insertOne({ userId, code: "482913", expireAt: new Date(Date.now() + 5 * 60 * 1000) })
Assuming documents vanish at the exact second, or storing the expiry as a string.
Mode: run explain with executionStats so you see what actually ran, not just the chosen plan.
Stages: COLLSCAN is a full scan; IXSCAN then FETCH is normal; a SORT stage means an in-memory sort.
Ratios: compare documents returned with keys and documents examined; close together is healthy.
"I run the query with explain in executionStats mode, so I see what actually happened. First I look at the winning plan's stages. A COLLSCAN on a big collection means no index was used. IXSCAN followed by FETCH is the normal good case. A SORT stage means the results were sorted in memory, which usually tells me the index doesn't match the sort. Then I compare three numbers: nReturned, totalKeysExamined and totalDocsExamined. In a healthy query they're close. If it returned twenty documents but examined two hundred thousand, the index is either missing a field or the fields are in the wrong order. If totalDocsExamined is zero, the index covered the whole query, which is the best case. I also glance at executionTimeMillis and at rejected plans, to see what else the planner considered."
db.orders.find({ customerId: cid, status: "paid" })
.sort({ createdAt: -1 })
.limit(20)
.explain("executionStats")
Only checking that an index was used, without comparing documents examined against documents returned.
The trap: a plain unique index treats a missing field as null, so only one user can lack an email.
Sparse: skips documents without the field, but an explicit null still collides.
Partial: a unique index with a filter such as "email is a string" indexes only real emails.
Query rule: the planner uses a partial index only when the query fits inside its filter.
"A plain unique index on email won't work, because documents without the field are indexed as null, so the second phone-only user fails with a duplicate key error. A sparse unique index skips documents that don't have the field, which helps, but if any code writes email as an explicit null, those still collide. What I'd use is a partial index: unique on email, with a filter that only includes documents where email is a string. Now phone-only users aren't in the index at all, and real emails are still unique. Two more things. The planner only picks a partial index when it can tell the query's filter falls inside the index filter, so I confirm with explain. And I lowercase and trim emails before saving, or two users could register the same address with different capital letters."
db.users.createIndex(
{ email: 1 },
{ unique: true, partialFilterExpression: { email: { $type: "string" } } }
)
Believing a unique index ignores documents that lack the field, or enforcing uniqueness only with a check in app code.
Usage stats: check index usage counters on every member that serves reads; they reset on restart.
Hidden jobs: an index may enforce uniqueness or expire documents even if no query uses it.
Overlap: it may be redundant because it is a prefix of another index.
Test: hide the index first, watch, then drop.
"Good instinct, since every index costs something on every write, but I'd check a few things first. I'd look at the index usage stats, which count how often each index was used. Those counters are per server and reset when a server restarts, so I check every member that serves reads, over a period that covers monthly jobs and reports, not just one quiet afternoon. Next, I'd check whether it does a job besides speeding up queries. A unique index guards data, and a TTL index deletes expired documents, so dropping either changes behaviour even with zero reads. I'd also see whether another index already starts with the same fields, which makes this one redundant. Then, rather than dropping it straight away, I'd hide it. A hidden index is still maintained but the planner ignores it, so if something slows down I can unhide it instantly instead of waiting for a rebuild. After a week or two with no problems, I drop it."
Dropping it because one server shows zero uses since last week, without checking uniqueness, TTL or other members.
Model: documents flow through stages in order; each stage reshapes the stream for the next.
Everyday stages: match to filter, group to summarise, project to shape, sort and limit, unwind to flatten arrays, lookup to join.
Order matters: filter and sort early so they can use indexes and later stages see fewer documents.
"The aggregation pipeline is a list of stages, and documents flow through them in order, like an assembly line. Each stage takes the documents from the one before and passes a new stream on. The ones I use most are match, which filters just like a find; group, which buckets documents by a key and computes sums, counts or averages; project or its cousin addFields, to rename, compute or drop fields; sort and limit; unwind, which turns one document with an array into one document per element; and lookup, which pulls matching documents from another collection, a bit like a left join. Order matters a lot. A match at the start can use an index and cuts down what every later stage has to handle, so I filter as early as possible. The server does reorder some stages to help, but I don't count on that."
Putting the filter at the end of the pipeline, or not knowing that only early stages can use indexes.
Filter first: match on status and the date range, backed by an index.
Summarise: group by customer id with a sum of amounts and a count.
Rank: sort by total descending and limit to five.
Enrich last: lookup customer names only for those five, then shape the output.
"I'd start with a match on status completed and createdAt within last month, using greater-than-or-equal the first day and less-than the first day of this month, so I don't miss orders late on the last day. An index on status and createdAt makes that fast. Then a group on customerId, summing the amount and counting orders. Then sort by total descending and limit five. The server combines sort followed by limit into a top five, so it doesn't sort everyone in full. Only after that do I do the lookup into customers, so I join five documents instead of thousands. Unwind turns the single-element customer array into a plain object, and a final project picks out the name, total and order count. In real code I'd compute the two dates in the app rather than hard-code them."
db.orders.aggregate([
{ $match: { status: "completed",
createdAt: { $gte: ISODate("2026-08-01"), $lt: ISODate("2026-09-01") } } },
{ $group: { _id: "$customerId", total: { $sum: "$amount" }, orders: { $sum: 1 } } },
{ $sort: { total: -1 } },
{ $limit: 5 },
{ $lookup: { from: "customers", localField: "_id", foreignField: "_id", as: "customer" } },
{ $unwind: "$customer" },
{ $project: { _id: 0, name: "$customer.name", total: 1, orders: 1 } }
])
Doing the lookup before grouping, which joins every order, or using less-than-or-equal on the last day and missing late orders.
Cause: blocking stages like group and sort hold data in memory, and each stage has a cap of about 100 megabytes.
Real fixes: match early, drop unneeded fields before grouping, let an index provide the sort.
Spill to disk: allowing disk use lets those stages spill; it works but is slower.
Different limit: a single output document over 16 megabytes is a design problem, not memory.
"Stages like group and sort can't stream. They have to hold data before they can output anything, and each of those stages has a memory limit of about 100 megabytes. When the working set passes that and spilling to disk isn't allowed, the pipeline fails. My first fixes are to the pipeline itself. I move the match as early as possible so fewer documents go in, add a project before the group so each document carries only the fields I need, and put the sort right after the match so an index can supply the order instead of an in-memory sort. Then there's the allowDiskUse option, which lets those stages write temporary files. Newer versions do that by default, but it's slower. One trap is different: if a group pushes everything into one array, it can hit the 16 megabyte document limit, and disk won't fix that. For reports that run every day, I'd precompute results into a summary collection with a merge stage."
Only saying "turn on allowDiskUse" without reducing the data flowing through the pipeline.
Roles: one primary takes writes and records them in the oplog; secondaries copy and replay it.
Election: members exchange heartbeats; if the primary is unreachable, a secondary calls an election and needs a majority of votes.
App impact: a short window with no primary; retryable writes and a replica set connection string ride through it.
Rollback risk: writes only the old primary had can be rolled back; majority write concern prevents that.
"A replica set is a group of mongod servers holding the same data, usually three. One is the primary and takes all writes, recording each change in the oplog, a special capped collection. The secondaries tail that oplog and apply the same changes, a little behind. Members send each other heartbeats every couple of seconds. If the secondaries lose contact with the primary for the election timeout, which is ten seconds by default, an eligible secondary calls an election and needs votes from a majority of voting members. That's why you want an odd number of voters. For those seconds there's no primary, so writes fail, and drivers with retryable writes will retry once after the new primary is found. One subtle thing: if the old primary accepted writes that never reached a secondary, those get rolled back when it rejoins. Majority write concern is how I avoid losing them."
Saying secondaries share the write load, or that a failover is instant and loses nothing whatever the write concern.
Setup: mongos routers send queries to shards using ranges kept on config servers; a balancer moves ranges.
Good key: many distinct values, evenly used, not steadily increasing, and present in most queries.
Bad keys: a timestamp gives one hot shard; a low-variety field gives chunks that cannot split; a field missing from queries means asking every shard.
Options: hashed keys spread writes but make range queries go to every shard; compound keys balance both.
"In a sharded cluster, apps talk to mongos routers, which use metadata on the config servers to send each query to the shards that own that range of the shard key. So the key decides both where data lives and how queries are routed. I want a key with lots of distinct values, used fairly evenly, that doesn't just keep increasing, and that appears in most of my queries. The classic mistakes: shard on createdAt or an ObjectId and every new insert lands on the last range, so one shard does all the writing. Shard on something like country and a big country becomes a chunk that can't be split. Pick a key the queries don't include and every read is broadcast to all shards. A hashed key fixes the hot shard but turns range queries into broadcasts. For orders, I'd often use customerId plus orderDate. Newer versions can reshard, but it's heavy, so I choose carefully up front."
Choosing _id or a timestamp without mentioning hot shards, or saying the shard key can be changed any time at no cost.
Write concern: how many members must confirm a write: 1, a number, or majority; plus journal and a timeout.
Read preference: which member serves reads: primary, primaryPreferred, secondary, secondaryPreferred, nearest.
Read concern: how durable the returned data must be, such as local or majority.
Payment: majority with journal, read from the primary; send analytics to secondaries.
"Write concern is how much confirmation I wait for before a write counts as done. With w set to one, only the primary confirms, which is fast but can be rolled back if the primary fails right after. With majority, most members have it, so it survives a failover. I can also ask for the journal to be flushed and set a timeout so a write doesn't hang forever. Read preference is about where reads go. Primary is the default and always current. Secondary or nearest spreads load but can return data that's a bit behind. There's also read concern, which says how durable the data I read must be, and majority means it won't be rolled back. For a payment record I'd write with majority and journal, and read from the primary with majority read concern, because being wrong about money is worse than a few extra milliseconds. Dashboards and reports can read from secondaries."
Mixing up write concern and read preference, or reading balances from secondaries without mentioning lag.
Basics: start a session, run operations with that session, commit or abort; changes apply all or nothing.
Helper: withTransaction retries on transient errors; throwing inside it aborts.
Costs: extra overhead, write conflicts that force retries, a time limit, slower across shards.
Design first: a single-document write is already atomic, so model data that changes together into one document.
"Transactions work on replica sets and sharded clusters, not on a standalone server. I start a session, run my operations passing that session, and commit, and either everything applies or nothing does. In the Node driver I use withTransaction, which retries the whole callback on transient errors, like a write conflict or a failover mid-way. If I throw inside it, the transaction aborts. A money transfer is the textbook case: take from one account only if the balance is enough, add to the other, and throw if the first update didn't match. The reason people say don't lean on them is cost. They hold resources, conflict with each other and must retry, have a time limit after which they're aborted, and are slower across shards. And a write to one document is already atomic. So I try to design so data that must change together sits in one document, and keep transactions for the real cross-document cases."
const session = client.startSession();
try {
await session.withTransaction(async () => {
const from = await accounts.updateOne(
{ _id: fromId, balance: { $gte: amount } },
{ $inc: { balance: -amount } }, { session });
if (from.modifiedCount !== 1) throw new Error("Insufficient balance");
await accounts.updateOne({ _id: toId }, { $inc: { balance: amount } }, { session });
});
} finally {
await session.endSession();
}
Saying MongoDB has no transactions, or wrapping every write in one by habit.
Diagnose: writes go to the primary, reads hit a secondary that has not applied them yet.
Quick fix: read your own writes from the primary on screens right after a save.
Stronger fix: causally consistent sessions with majority read and write concern.
Rethink: keep secondary reads for reports, and check the lag itself.
"That's almost certainly replication lag. The save goes to the primary, but the next page load reads from a secondary that hasn't applied it yet, so the user sees the old version. First I'd confirm by checking replication lag on the secondaries and matching complaint times to lag spikes. Then I'd fix the reads that matter. The simplest fix is to read from the primary on any screen a user sees right after changing something, like their profile or cart. If we need secondary reads there, MongoDB supports causally consistent sessions: with majority write and read concern, a read in the same session is guaranteed to see that session's earlier writes. I'd also question the setup. Secondary reads don't add write capacity, and during a failover the load all lands on fewer servers anyway. I'd keep them for reports and analytics, and look at indexes before adding read traffic to secondaries."
Blaming the frontend cache without checking replication, or switching every read to the primary without asking why secondaries were used.
Adds: schemas and models, type casting, validation, defaults, middleware hooks, virtuals and populate.
Gaps: rules exist only in app code; update validators are off by default; populate is extra queries, not a join.
Speed: lean returns plain objects, much lighter for read-heavy code.
Skip it: for heavy aggregation work, very hot paths, or services that want a thin layer.
"Mongoose is an ODM on top of the Node driver. It lets me define a schema and a model, and then it casts types, so a string id becomes an ObjectId, applies defaults, runs validation, and gives me pre and post hooks, virtual fields and populate to fill in referenced documents. That structure helps a team a lot. But I keep its limits in mind. The schema only exists in my app, so another service or a shell script can still write anything. Validators don't run on update queries unless I turn on runValidators. Populate is just extra queries behind the scenes, not a server-side join. And unique in a schema builds a unique index, it isn't a validator. For read-heavy endpoints I add lean, which skips building full documents. I'd skip Mongoose for a small service that's mostly aggregations or needs every bit of speed."
const userSchema = new mongoose.Schema({
email: { type: String, required: true, unique: true, lowercase: true, trim: true },
name: String,
createdAt: { type: Date, default: Date.now }
});
const User = mongoose.model("User", userSchema);
const recent = await User.find({ name: /ann/i }).sort({ createdAt: -1 }).limit(20).lean();
Thinking the Mongoose schema is enforced by the database, or that populate is a real join.
Signal: what showed the problem, such as slow API timings or the slow query log.
Diagnosis: the explain output and the numbers that gave it away.
Fix: the index or query change and how you rolled it out safely.
Proof and habit: the before and after, and what you changed in the team process.
"At my last company, an order history endpoint went from snappy to several seconds as our biggest customers grew. Our API timings showed it, and the database's slow query log pointed to one find on orders, filtered by customerId and status and sorted by createdAt. Explain showed it using an index on customerId alone, then a SORT stage in memory, examining tens of thousands of documents to return twenty. The fix was a compound index on customerId, status and createdAt, in that order, so equality first and sort next. I built it during a quiet hour and watched replication lag while it built. After that the endpoint came back in tens of milliseconds, and explain showed keys examined close to documents returned. The old single-field index was now a prefix of the new one, so I dropped it after checking it wasn't used elsewhere. We then added an explain check to code review for new queries."
A story where the fix was "we added indexes" with no numbers, no explain output, and no check that it worked.
Decision: what you chose and why it looked right at the time.
Symptom: how it failed as data grew.
Migration: the new model and a safe rollout, including backfill and reads during the switch.
Lesson: the rule you now apply up front.
"In a side product at my last job, we embedded each user's activity feed as an array inside the user document. It was simple, and one read gave the profile page everything. A year later, our most active users had thousands of entries. Loading a profile pulled megabytes, every new event rewrote a big document, and a couple of accounts were creeping toward the document size limit. We moved activity into its own collection, one document per event with userId and createdAt, and a compound index on those. We kept the latest five embedded for the profile header. The rollout went in steps: new events written to both places, a backfill script moving old arrays in batches, reads switched to the new collection, and finally the big arrays removed. The lesson I carry now is to ask, for every array, what's the most items this could ever hold."
Blaming MongoDB for the problem, or describing a one-shot migration with the app offline and no way back.
Event: what happened on the database side.
Impact: what users or services saw, and how you knew.
Fixes: connection string, retryable writes, idempotent operations, timeouts.
Practice: how you tested failover on purpose afterwards.
"At my last company we had a routine maintenance stepdown on a three-member replica set, and for about fifteen seconds our checkout API threw errors. When we dug in, one old service had a connection string with only the primary's host instead of the replica set seed list, so it didn't find the new primary for a while. Another was on an old driver without retryable writes, so every write in that window failed instead of retrying once. We fixed the connection strings, upgraded the driver, and made sure our order writes were idempotent with a client-generated order id and a unique index, so a retry couldn't create a double order. We also set sensible server selection timeouts. Then we started stepping down the primary on purpose in staging every release. The next real failover was a blip nobody outside the team noticed."
Treating failover as a rare disaster rather than a normal event the app must handle, or having no follow-up changes.
ClapAssist is an AI interview assistant for Mac and Windows. It listens to the interview on your computer and shows you what to say, in short lines you can read while you talk. Your resume and notes are never stored on our servers. It stays out of screen share on every plan; only you can see it.