Full-Stack Questions

MongoDB Interview Questions

Schema design, indexes, aggregation, transactions, replica sets, sharding and common anti-patterns.

Lesson 0731 min read#mongodb

On this page

  1. Words used in this lesson
  2. Document model vs relational
  3. Embed or reference?
  4. id and ObjectId
  5. Indexes
  6. The aggregation pipeline
  7. Transactions, read concern and write concern
  8. Replica sets and elections
  9. Sharding
  10. Change streams
  11. Atlas Search and Vector Search
  12. Mongoose vs native driver vs Prisma
  13. Validation, pagination, soft delete, migrations, backup, security
  14. Common mistakes
  15. Interview questions
  16. Key takeaways
  17. What's next

In one line: MongoDB stores documents (JSON-like objects), so you design the schema around how the application reads the data: put together what is read together, index for your real queries (equality, then sort, then range), and use transactions, replica sets and shards only when you understand what they cost.

You already know the SQL side of indexes, transactions and isolation from Backend, Lesson 07, and the ORM traps from Backend, Lesson 08. This lesson covers what is different in MongoDB, and the questions interviewers ask at 4+ years. They rarely ask "what is a collection?". They ask "your orders query is slow, and the document keeps growing: what do you do?"

Words used in this lesson

  • MongoDB is a database that stores data as documents (objects with fields, arrays and nested objects) instead of table rows. A collection is a group of documents, like a table. The documents in one collection do not need the same fields.
  • BSON is the binary format MongoDB uses to store JSON-like data. It has more types than JSON: dates, ObjectId, 64-bit integers and decimals.
  • A replica set is a group of MongoDB servers that keep the same data. One is the primary (takes writes). The others are secondaries (copy the primary's changes from its oplog, a log of every change).
  • A shard is one replica set that holds part of a big collection. Sharding splits a collection across shards. A shard key is the field that decides which shard gets a document.
  • An index is a sorted copy of some fields that lets the database find documents without reading the whole collection. A collection scan (COLLSCAN) reads every document.
  • An aggregation pipeline is a list of stages. Each stage takes documents in, changes them and passes them on, like a factory line.
  • ACID means a transaction is all-or-nothing and safe even if the server crashes.

1. Document model vs relational

Relational (SQL)MongoDB
Unit of dataRow in a tableDocument in a collection
SchemaFixed by the tableFlexible; you can add rules (validation)
Related dataSeparate tables, joined at read timeOften nested inside one document
AtomicityA transactionOne document is always atomic, even with nested data
JoinsBuilt in, normal$lookup exists but is the exception
Scaling outHarderBuilt in (sharding)

Difference in one line: in SQL you normalise first (no duplicate data) and join later; in MongoDB you model for the queries first and accept some duplicate data.

The strongest point: a write to one document is atomic. If an order and its line items live in one document, you do not need a transaction to update them together. "Flexible schema" does not mean "no schema". Your application still has a shape. The shape is just kept in code (and in validation rules) instead of only in the database.

2. Embed or reference?

Embedding means putting related data inside the parent document. Referencing means storing the _id of another document and loading it with a second query (or $lookup).

Choose embedding whenChoose referencing when
You read the parts together almost every timeYou often read the child alone
The child belongs to one parent ("has a")Many parents share the child (many-to-many)
The list stays small and boundedThe list can grow without limit
The child changes rarely or only with the parentThe child changes often, on its own
You want one atomic updateThe data would make the parent huge

Rule of thumb: "one-to-few" embeds, "one-to-many" often references, "one-to-squillions" always references (the child holds the parent's _id).

Patterns you can name in an interview:

  • Subset: keep only the data the page needs in the main document (for example the last 10 reviews) and the rest in another collection. This keeps the working set small.
  • Extended reference: copy a few fields you always show (the customer's name) into the order, so the common read needs no join. The cost: you must update copies when the source changes. Copy only fields that rarely change, or that should be frozen anyway (the price at order time).
  • Bucket: group many small items into one document per time window (one document per sensor per hour). Fewer documents, fewer index entries.
  • Computed: store a calculated value (a count, a total) and update it on write, so reads are cheap.
  • Schema versioning: keep a schemaVersion number in each document, so old and new shapes can live together while you migrate.
  • Attribute (many rare fields as { k, v } pairs, one index) and Archive (move old data to cheaper storage) are the other two names to know.

Unbounded arrays are the number one anti-pattern. A document has a hard limit of 16 MiB. An array that grows on every event (comments, log lines, order history) will hit that limit, makes every write rewrite a bigger document, and bloats the index. Bound it, or move the children into their own collection.

3. _id and ObjectId

Every document has a unique _id, and it is always indexed. If you do not give one, the driver makes an ObjectId. It is 12 bytes: a 4-byte timestamp (seconds), a 5-byte random value (per process) and a 3-byte counter. It prints as 24 hex characters. I generated some:

JavaScript
const a = new ObjectId();            // 6ac7b2bd40ac35250d1f3873a.getTimestamp();                    // 2026-10-08T15:11:57.000Z  (first 4 bytes)

Two ObjectIds made by the same process in the same second share the first nine bytes, and only the counter changes. Facts to say out loud:

  • ObjectIds are roughly ordered by time but not strictly: they have one-second resolution and the clocks of different clients differ. Do not use _id order as a perfect timeline; add a createdAt.
  • The ID is made by the driver, not the server, so you know it before the insert (useful for retries).
  • _id is a good key for cursor pagination, but a bad shard key (it grows with time, see section 8).
  • In JSON, an ObjectId becomes a string. Comparing String to ObjectId in a filter finds nothing, a very common bug: { _id: "6ac7..." } does not match { _id: ObjectId("6ac7...") }. Convert with new ObjectId(str). (Mongoose casts for you; the native driver does not.)

4. Indexes

An index speeds up reads and slows every write a little, and it uses memory. Know these types:

IndexWhat it isNotes
Single fieldOne field_id has one automatically
CompoundSeveral fields, order mattersThe first fields are the "prefix"; a query can use a prefix
MultikeyAn index on an array field; one entry per elementA compound index can have at most one array field
TextWord searchA collection can have only one text index; basic compared with a search engine
TTLDeletes documents after a timeSingle field, must be a date (or array of dates); a background job runs about every 60 seconds, so deletion is not exact
PartialIndexes only documents that match a filterSmaller and cheaper; a query must include the filter to use it
UniqueNo two documents may have the same valueA missing field counts as null, see below

Limits: up to 64 indexes per collection and 32 fields in a compound index.

The ESR rule (Equality, Sort, Range)

For a compound index, order the fields: first the fields you match with equality, then the field you sort by, then the fields with a range ($gt, $lt, $in with many values, $ne, $regex). Example query:

JavaScript
db.orders.find({ userId, status: "paid", total: { $gt: 100 } }).sort({ createdAt: -1 })// index:  { userId: 1, status: 1, createdAt: -1, total: 1 }//          equality-----------   sort--------    range

Why: equality fields narrow the index to one small slice that is already in sorted order, so the sort comes free. A range field before the sort breaks that order, so MongoDB would need an in-memory sort. ESR is a guideline, not a law: if the range is very selective, putting it before the sort (ERS) can be faster, even with an in-memory sort. Test with explain.

Reading explain()

explain("executionStats") shows what MongoDB did. Look at:

  • Stage names: COLLSCAN (read everything, usually bad on big collections), IXSCAN (used an index), FETCH (load full documents), SORT (in-memory sort; slow on large data).
  • totalKeysExamined, totalDocsExamined vs nReturned. If you return 20 documents but examine 200,000, the index is wrong or missing. Aim for these three numbers to be close.
  • A covered query reads only the index (no FETCH), because all needed fields are in the index and are projected.

I could not run a real server for this lesson, so these names come from the official docs, not from my own run.

Index traps

  • Unique index and missing fields. A unique index treats a missing field as null. The second document without email fails with a duplicate error. Fix: a partial unique index (partialFilterExpression: { email: { $exists: true } }), so documents with no email field are left out. (A document with an explicit email: null still has the field, so store a real value or leave the field out.)
  • Partial index filters are limited: equality, $exists: true, $gt/$gte/$lt/$lte, $type, $and, $or, $in. For "not deleted" use a real boolean such as isDeleted: false.
  • Array queries. { "items.sku": "a", "items.qty": { $gt: 1 } } can match when different array elements satisfy each part. I tested a document with [{sku:"a",qty:1},{sku:"b",qty:5}]: the dotted form matched, and $elemMatch: { sku: "a", qty: { $gt: 1 } } did not. Use $elemMatch when both conditions must hold for one element.
  • Too many indexes. Each costs write speed and memory. Drop unused ones; {a:1} is not needed if you have {a:1,b:1}. Build indexes on big collections in a quiet time, and with Mongoose set autoIndex: false in production.

5. The aggregation pipeline

A pipeline is an array of stages. The ones you must know:

StageWhat it does
$matchFilter documents (can use an index when it is first)
$groupGroup by a key and compute $sum, $avg, $push and so on
$sort, $limit, $skipOrder and cut
$project / $addFieldsChoose or compute fields
$unwindTurn each element of an array into its own document
$lookupA left outer join with another collection
$facetRun several sub-pipelines on the same input, in one pass
$merge / $outWrite the result into a collection

A worked example. Orders paid per customer, with the customer name:

JavaScript
db.orders.aggregate([  { $match: { status: "paid" } },                                   // 1. filter first, uses an index  { $group: { _id: "$userId", revenue: { $sum: "$total" }, orders: { $sum: 1 } } },  { $sort: { revenue: -1 } },  { $lookup: { from: "users", localField: "_id", foreignField: "_id", as: "user" } }, // 2. join AFTER shrinking  { $unwind: "$user" },  { $project: { _id: 0, name: "$user.name", revenue: 1, orders: 1 } },])// [ { name: "Ben", orders: 1, revenue: 250 }, { name: "Asha", orders: 2, revenue: 200 } ]

I ran this logic on four sample orders; the result is the one in the comment. Things to know:

  • Put $match (and $sort + $limit) as early as possible so later stages see fewer documents.
  • $lookup needs an index on the foreignField. Without it, each input document scans the other collection. Do the $group first so you join 2 rows, not 2 million.
  • $unwind drops documents whose array is empty or missing, unless you set preserveNullAndEmptyArrays: true. I tested this: an order with items: [] disappeared after a plain $unwind.
  • $facet gives several results in one request (for example a result list and counts by status for a search page). Each stage inside is limited to 100 MB (no spilling to disk), the whole output must fit in one 16 MiB document, and a $facet that comes first does a collection scan, so put $match before it.
  • Memory: a stage like $group over 100 MB writes temporary files to disk by default since MongoDB 6.0.
  • $merge writes results into a collection (insert, replace, merge or fail on match). It needs a unique index on the on fields, and the index cannot be partial. It is the base for "materialised views": a nightly job pre-computes a summary and the app reads that.

6. Transactions, read concern and write concern

A transaction groups several operations so they all succeed or all fail. MongoDB supports multi-document transactions on replica sets and sharded clusters. They are not free: they hold resources, they have a time limit (60 seconds by default), and they conflict with other writers. Good modelling often removes the need.

JavaScript
const session = client.startSession();try {  await session.withTransaction(async () => {    const res = await orders.updateOne({ _id: orderId, status: "new" }, { $set: { status: "paid" } }, { session });    if (res.modifiedCount !== 1) throw new Error("order not payable");    await ledger.insertOne({ orderId, amountCents, at: new Date() }, { session });  }, { readConcern: { level: "snapshot" }, writeConcern: { w: "majority" } });} finally { await session.endSession(); }

Rules from the driver documentation, worth remembering:

  • Pass { session } to every operation. One that forgets it runs outside the transaction.
  • withTransaction may call your function more than once (it retries after transient errors), so do not send emails or call payment APIs inside. Keep it to database work.
  • Do not run operations in parallel inside it (Promise.all is undefined behaviour). Await them one by one.
  • Do not swallow errors inside the callback, or the driver cannot tell the transaction failed.
  • Keep it short. Long transactions create cache pressure and write conflicts. Two transactions that touch the same document do not wait; one fails with a write conflict and you retry.

Write concern says how many servers must confirm a write before you get "OK". w: 1 is the primary only. w: "majority" means a majority of the voting members have it, so it survives a failover. Since MongoDB 5.0 the default is majority (with one edge case: if arbiters leave too few data-bearing voting members, the default falls back to w: 1). Read concern says how safe the data you read is. The default "local" can return data that is later rolled back. "majority" returns only data confirmed by a majority. "snapshot" (in transactions) gives one consistent view. Drivers have retryable writes and reads on by default (retryWrites=true), which retry once after a network error or failover, but not inside a transaction.

A transaction is for "move money from A to B". It is not for "update one order and its embedded items": that is already atomic.

7. Replica sets and elections

A replica set has one primary and several secondaries. If the primary stops answering, the secondaries hold an election and one becomes primary. By default a secondary calls an election when it has not heard from the primary for 10 seconds (electionTimeoutMillis). The docs say the median time to get a new primary is under about 12 seconds with default settings. During that time there are no writes.

  • Use an odd number of voting members (3 or 5) so a majority exists. With 3 members, you can lose 1. An election needs a majority of voting members.
  • An arbiter votes but holds no data. It weakens w: "majority", so avoid it when you can.
  • Reads go to the primary by default. Secondary reads can be stale (replication lag); use them only for reports.
  • If a primary that took a write that never reached a majority fails, that write is rolled back when the old primary comes back. w: "majority" prevents surprise rollbacks.
  • Transactions and change streams need a replica set (a one-node replica set is fine for local development).

8. Sharding

Sharding spreads one collection over several shards. The mongos router sends each query to the right shard(s) using the shard key. A query that includes the shard key is targeted (one shard). One that does not is scatter-gather (all shards, slower).

A good shard key has:

  1. High cardinality: many distinct values. A key like country with a few values limits you to few chunks.
  2. Low frequency: no single value holds a big share of the data (one huge customer).
  3. No steady growth: a key that only goes up (a timestamp, an ObjectId) sends all new inserts to the last chunk, so one shard is a hot shard and the rest idle.
  4. Matches your queries: most queries should contain it.

The fixes: a hashed shard key (even spread, but range queries become scatter-gather), or a compound key such as { tenantId: 1, _id: 1 } (even spread across tenants, and tenant queries stay targeted). Since MongoDB 5.0 you can change the key with reshardCollection, and refineCollectionShardKey can add suffix fields. This is possible but heavy, so choose carefully. Default chunk (range) size is 128 MB. And remember: most apps never need sharding. Try better indexes, a bigger machine, or archiving first.

9. Change streams

A change stream lets your app subscribe to changes (insert, update, delete) on a collection, database or cluster, without reading the oplog yourself. It needs a replica set.

JavaScript
const stream = orders.watch(  [{ $match: { operationType: { $in: ["insert", "update"] } } }],  { fullDocument: "updateLookup", ...(savedToken ? { resumeAfter: savedToken } : {}) },);for await (const change of stream) {  await handle(change);  await saveToken(change._id);     // the resume token}
  • Save the resume token (change._id) after you handle each event. If your process restarts, pass it as resumeAfter and you continue without gaps, as long as the oplog still holds that point. Use the same pipeline and options when you resume.
  • fullDocument: "updateLookup" fetches the current document when the event is read. It can be newer than the change itself. For the exact before and after, enable changeStreamPreAndPostImages on the collection (MongoDB 6.0 and later).
  • Typical uses: keep a search index in sync (for example Meilisearch), invalidate caches, feed a queue. Handlers must be idempotent (safe to run twice), because an event can arrive again after a restart.

MongoDB Atlas is MongoDB's hosted service. Two features of it matter now:

  • Atlas Search is full-text search (word matching, ranking, typo tolerance, autocomplete) built into Atlas. You define a search index and use the $search stage in an aggregation. The plain text index is basic; Atlas Search is the real option, and it avoids running a separate engine. A separate engine like Meilisearch can feel better for some apps, but you must sync it (for example with a change stream) and run it.
  • Vector Search finds documents by meaning, using embeddings (arrays of numbers produced by an AI model; similar meaning gives close numbers). It is the "R" in RAG (retrieval-augmented generation: find relevant text, then give it to an LLM). You create a vector search index and use $vectorSearch:
JavaScript
db.docs.aggregate([  { $vectorSearch: { index: "vec_idx", path: "embedding", queryVector, numCandidates: 200, limit: 10, filter: { lang: "en" } } },  { $project: { _id: 0, title: 1, score: { $meta: "vectorSearchScore" } } },])

numCandidates is how many nearest candidates the approximate (ANN) search looks at, and limit is how many you get back. A larger numCandidates gives better recall but costs more time. Use the same embedding model for documents and query. Both features run on Atlas, so ask which the team uses.

11. Mongoose vs native driver vs Prisma

Native mongodb driverMongoosePrisma
ModelPlain objectsSchemas, models, hooksSchema file, generated client
ValidationServer rules onlyIn the app (required, enum)Types from the schema
CostYou write moreExtra CPU and memory per document (use lean())Fewer Mongo-only features

Facts I checked:

  • Driver defaults: maxPoolSize 100, serverSelectionTimeoutMS 30,000, retryWrites and retryReads true. Create one MongoClient per process and reuse it. A client per request creates connection storms.
  • Mongoose: unique: true creates an index; it is not a validator (a duplicate gives an error from the server, code 11000). Unknown fields are dropped by strict mode (I saw an extra field vanish). populate() runs a separate query, not a server-side join. lean() skips building full Mongoose documents: faster, but you get plain objects without methods. Without a connection, calls wait (buffer) and fail after bufferTimeoutMS (10 s default); I saw Operation users.find() buffering timed out.
  • In Mongoose 9: pre hooks no longer take next (use async functions), validateSync() is deprecated (use await doc.validate()), new: true is replaced by returnDocument: "after", and isValidObjectId(6) is now false.
  • Prisma with MongoDB: Prisma's own docs say it uses transactions for nested writes, so you need a replica set; ids are mapped with @map("_id") and @db.ObjectId; you use db push (there are no SQL-style migrations); it cannot tell null from a missing field; and count() on huge collections is slow. Support changed between major versions: the v7 docs say MongoDB support for v7 is "coming" and tell you to stay on v6 (6.19) for MongoDB, and v8 (a release candidate as of this writing) has a new, different MongoDB package. Check your Prisma version before you promise anything in an interview.

12. Validation, pagination, soft delete, migrations, backup, security

Schema validation. MongoDB can enforce a $jsonSchema: required fields, types and ranges. validationAction: "error" rejects bad writes; "warn" only logs them. validationLevel: "moderate" checks new inserts and updates of already-valid documents, and leaves old invalid ones alone, so you can add rules to messy data. Keep app-level validation too (Backend, Lesson 03) for better error messages.

Pagination. skip(n) makes the server walk past n entries, so page 5,000 is slow. Use range (keyset) pagination: find({ _id: { $gt: lastId } }).sort({ _id: 1 }).limit(20). For another sort field, sort by { createdAt, _id } and filter on both, so ties have a stable order. Fetch limit + 1 rows to know if there is a next page, and return an opaque cursor. countDocuments() with a filter can be slow on big collections; estimatedDocumentCount() is fast but cannot filter.

Soft delete. Store isDeleted: false (set at creation, so a partial index sees it) and deletedAt. Every read must filter on it, so use one helper. A unique index with partialFilterExpression: { isDeleted: false } lets a user re-register an email after deleting. Purge or archive later with a job. Trade-off: easy undo and audit, but every query must remember the filter, and privacy rules may still need real deletion.

Migrations. Schemaless does not mean migration-free. Use schema versioning: a schemaVersion field, code that reads both shapes, and a script that updates small idempotent batches (updateMany({ schemaVersion: { $lt: 2 } }, ...)). Release in steps: write both shapes, backfill, switch reads, remove the old. A runner such as migrate-mongo keeps scripts in order.

Backup. In Atlas use continuous cloud backup with point-in-time restore. Self-hosted: filesystem or volume snapshots, or Ops/Cloud Manager. mongodump and mongorestore are for small deployments; the docs call them tools for small ones. Plain file copies need writes stopped. A backup is real only after you practise a restore and measure how long it takes.

Security.

  • Turn on authentication and give each app a user with the least rights. Do not expose the port to the internet; the server listens on localhost by default, so you must open it on purpose. Use TLS, IP allow-lists and private networking.
  • NoSQL injection is real. If req.body.password can be an object, { "password": { "$ne": "" } } matches any account. I tested this: the filter matched the admin. Defences: validate that fields are strings first (Backend, Lesson 03), and in Mongoose use sanitizeFilter, which wraps objects in $eq (I saw { $ne: "" } become { $eq: { $ne: "" } }). Avoid $where and server-side JavaScript.

Common mistakes

  1. Unbounded arrays and huge "everything" documents; $lookup as a habit.
  2. Wrong index order, or one index per field instead of a compound index.
  3. Comparing a string with an ObjectId.
  4. Transactions for what one document already does, or with side effects inside.
  5. A growing shard key (_id, timestamp); skip for deep pages.
  6. Trusting request JSON in filters (NoSQL injection).
  7. A new MongoClient per request.

Interview questions

(Must know) How do you decide between embedding and referencing?

I look at how the data is read and how it grows. If the child is read with the parent, belongs to one parent, and the list is small and bounded, I embed: fast reads and atomic updates. If the list can grow without limit, is shared by many parents, or is often read alone, I reference it. Often I mix: embed a small subset (latest 10 reviews) and keep the rest elsewhere. My test: "can this array ever get really big?" If yes, I do not embed it.

(Must know) What is an unbounded array and why is it a problem?

An array that grows with usage, like comments or event history inside one document. A document is limited to 16 MiB, big documents make every write heavier and use memory, and multikey indexes on the array grow too. I move the children to their own collection with the parent's _id, or use the bucket pattern, or keep only a recent subset embedded.

(Must know) What is the ESR rule? Give an example.

ESR means Equality, Sort, Range: the order of fields in a compound index. For find({ userId, status: "paid", total: { $gt: 100 } }).sort({ createdAt: -1 }) the index is { userId, status, createdAt, total }. Equality fields narrow to one slice of the index, which is already sorted, so the sort needs no extra step. A range field before the sort would break that. It is a guideline: if the range is very selective, putting it earlier can win. I confirm with explain.

(Senior signal) A query is slow. Walk me through explain().

I run explain("executionStats"). First the winning plan: COLLSCAN means no useful index; IXSCAN then FETCH is normal; a SORT stage means an in-memory sort. Then I compare totalKeysExamined and totalDocsExamined with nReturned. If I examine 100,000 documents to return 20, the index does not fit the filter and sort. I rebuild the compound index by ESR and test again. For a hot query I try for a covered query with a projection.

(Trap) A unique index on email rejects the second user who has no email. Why, and how do you fix it?

A unique index treats a missing field as null, and only one document may have null. So the second document without email conflicts. I create a partial unique index with partialFilterExpression: { email: { $exists: true } }, so only documents that have an email are in the index. Another fix is to make sure the field is always present. I would not use a placeholder value.

(Trap) { "items.sku": "a", "items.qty": { $gt: 1 } } returns an order where item "a" has quantity 1. Why?

For arrays, each dotted condition can be matched by a different element. Item a satisfies the first part, and item b with quantity 5 satisfies the second. I use $elemMatch: { sku: "a", qty: { $gt: 1 } } so both conditions must hold for the same element. The same applies in $pull and in index design. I tested this with a document that has two items.

(Must know) How would you write a "revenue per customer with the customer's name" pipeline, and what do you watch?

$match on paid orders first, so an index is used. $group by userId to sum totals. $sort. Then $lookup into users on _id, and $unwind the result, and $project the name. I do the $group before the $lookup so I join a few rows instead of millions, and I make sure the foreign field is indexed. For a heavy report I would write the output with $merge on a schedule and let the app read that collection.

(Trap) Why did some orders disappear after $unwind: "$items"?

$unwind removes documents where the array is empty or missing. I verified an order with items: [] gave zero results. If I need to keep them, I use { $unwind: { path: "$items", preserveNullAndEmptyArrays: true } }. This is a common cause of "my totals are lower than expected" in reports.

(Senior signal) When do you use a multi-document transaction in MongoDB, and what do you watch for?

When several documents must change together and I cannot model them as one document, for example moving money between two accounts. Single-document updates are already atomic, so I try to avoid a transaction by modelling. When I use one: withTransaction, the session on every operation, operations awaited one by one, no side effects (the callback can run again), short (60-second default limit), and retry on write conflicts. I use w: "majority" and often snapshot read concern.

(Must know) What are read concern and write concern? What are the defaults?

Write concern says how many members confirm a write. The default is w: "majority" since 5.0 (with an edge case when arbiters are present). Read concern says how safe the read data is. The default "local" can return data that is rolled back after a failover. "majority" returns only majority-confirmed data. Inside a transaction "snapshot" gives a consistent view. I raise the levels for money and permissions, and keep defaults for low-risk reads.

(Senior signal) What happens when the primary dies?

The secondaries miss heartbeats; after electionTimeoutMillis (10 s default) one calls an election and wins with a majority of voting members. In the meantime there are no writes. The docs say the median is under about 12 seconds. With retryable writes the driver retries once, so many failovers are invisible. Writes that did not reach a majority can be rolled back, which is why I use w: "majority" for important data. This is also why replica sets need an odd number of voting members.

(Senior signal) How do you choose a shard key, and what is a hot shard?

I want high cardinality, even frequency, no monotonic growth, and a key that most queries contain. A key that only grows, like _id or a timestamp, sends all inserts to the newest chunk, so one shard takes every write. That is a hot shard. A hashed key fixes insert spread but turns range queries into scatter-gather. For multi-tenant systems a compound key like { tenantId, _id } spreads tenants and keeps tenant queries on one shard. I also say that I would first try indexes, bigger hardware and archiving before sharding.

(Must know) What is a change stream and how do you use it safely?

It is a live feed of changes for a collection, database or cluster; it needs a replica set. I save the resume token after I handle each event, and on restart I pass it as resumeAfter with the same pipeline. It works only if the oplog is long enough. Handlers must be idempotent. updateLookup gives the latest document, not the exact one at that moment; for before and after images I enable pre- and post-images. I use it to sync a search index or invalidate caches.

(Senior signal) Atlas Search or Meilisearch or a text index? And what is Vector Search for?

A text index is basic and one per collection. Atlas Search gives ranking, typo tolerance and autocomplete next to the data, with nothing extra to sync, but only on Atlas. A separate engine like Meilisearch may give a nicer search experience, but I must sync it (for example from a change stream) and run it. Vector Search finds items by meaning using embeddings through $vectorSearch with numCandidates and limit. I use it to retrieve context for RAG, with the same embedding model for data and query.

(Must know) Mongoose, the native driver or Prisma for a new Node API?

If the team wants schemas, hooks and fast development on MongoDB only, Mongoose is the mainstream choice; I use lean() for read-only queries, turn off autoIndex in production, and sanitizeFilter for user input. If I need full control or every new server feature, I use the native driver. Prisma is attractive for type safety if the team already uses it with SQL, but MongoDB support has changed between major versions and needs a replica set, so I check the exact version first. In all cases there is one client per process.

(Trap) await User.find({ email, password }) is used for login, and an attacker sends {"password": {"$ne": ""}}. What happens?

The filter becomes "password is not empty", so it matches the account; the schema casts the operator happily, and I saw this match the admin in my test. This is NoSQL injection. The fixes: validate the body so email and password must be strings; never put the password in a query, instead find by email and compare a hash; and in Mongoose use sanitizeFilter so objects are wrapped in $eq. Also avoid $where.

(Senior signal) How do you paginate a big collection, and how do you implement soft delete?

For pagination I use keyset pagination: filter on the last seen sort value plus _id, sort by the same fields, and fetch limit + 1 to know if there is more. skip gets slower with depth. For soft delete I store isDeleted: false and deletedAt, filter on it in every query through one helper, and use partial indexes on isDeleted: false (including a unique index for live rows). I purge or archive old rows with a job. I mention the cost: every query must remember the filter, and privacy rules may still need real deletion.

(Senior signal) How do you change the shape of documents in a live collection, and how do you back up?

I use the schema-versioning pattern: each document has schemaVersion; code reads both shapes; a script updates in small idempotent batches; then I switch reads and remove the old field. I add $jsonSchema with validationLevel: "moderate" so new writes follow the rules while old rows are left alone. For backups, Atlas continuous backup or storage snapshots with point-in-time restore for real systems; mongodump only for small data. And I test a restore regularly, because an untested backup is only a guess.

Key takeaways

  • Model for your queries. Embed small, bounded, read-together data; reference growing or shared data. One document is atomic.
  • ObjectId is 12 bytes (time, random, counter), roughly ordered, made by the driver. Never compare it with a plain string.
  • Compound indexes: Equality, Sort, Range. Read explain and compare keys and documents examined with documents returned.
  • Know the traps: unique and missing fields, array conditions without $elemMatch, $unwind dropping empty arrays, $lookup without an index.
  • Transactions are the exception: pass the session, stay short, no side effects. Use w: "majority" for important writes.
  • Replica sets fail over in about ten seconds. A shard key must not grow forever. Most apps do not need sharding.
  • Change streams need a saved resume token and idempotent handlers. Vector Search powers RAG; Atlas features run on Atlas.
  • Validate input as strings to stop NoSQL injection, use sanitizeFilter, turn on auth, and test your restores.

What's next

You now have the Node and MongoDB layers. Go back to Backend in Depth for the SQL view of the same ideas (indexes, isolation, N+1), or practise end to end with Practical Design Problems.

Published by Jainik · Last updated · How these lessons are made

Keep learning

Related series