How MongoDB Works Internally: The WiredTiger Storage Engine, Document Model, and Replication Protocol Behind the Most Popular NoSQL Database
A deep dive into MongoDB internals covering WiredTiger B-tree storage and MVCC, BSON encoding, compound and wildcard indexes, the query planner's trial-period competition, oplog-based replication, Raft-inspired elections, sharding chunk mechanics, and tunable read/write concerns.
Most teams adopt MongoDB through the driver, write a filter object, and get documents back. That level of understanding is fine until a query becomes slow for no obvious reason, a replica set election during a deployment drops writes for thirty seconds, or a sharded collection develops a hot shard that absorbs all traffic. At that point, driver-level knowledge runs out. You need to understand what is happening below the API surface.
This article traces MongoDB’s execution path from write to disk, from oplog append to secondary application, and from query filter to index scan. The goal is a precise mental model that makes production failures diagnosable rather than mysterious.
The WiredTiger Storage Engine
MongoDB replaced MMAPv1 with WiredTiger as its default storage engine in version 3.2. WiredTiger is a general-purpose key-value store with B-tree primary storage, MVCC concurrency, and a write-ahead log for durability.
B-Tree Layout and RecordId
Each MongoDB collection is backed by a WiredTiger B-tree. The primary key in that tree is a 64-bit integer called a RecordId. Documents are stored as values under their RecordId. Secondary indexes are separate B-trees whose leaf entries hold (indexed value, RecordId) pairs. When a query uses a secondary index, WiredTiger first traverses the index B-tree to collect matching RecordId values, then performs a second lookup into the collection B-tree to fetch the full documents. That second lookup is the cost of a non-covered query: one random read per matched document.
WiredTiger leaf pages default to 4 KB on disk and 32 KB for internal pages. Pages are compressed using Snappy by default, with zstd available for significantly better compression at the cost of higher CPU usage on writes. Each collection and each index has its own data file under the dbpath directory.
MVCC and Document-Level Locking
WiredTiger implements multi-version concurrency control at the document level. Each write creates a new version of the record identified by a transaction timestamp. Readers holding an older snapshot continue to see the prior version without blocking writers, and writers do not block readers. The list of versions attached to a record is the update chain.
WiredTiger’s eviction threads reclaim memory by reconciling update chains: they write the current committed version back to the B-tree leaf page and discard older versions once no active transaction needs them. If a long-running aggregation or transaction holds a snapshot open for an extended period, older versions accumulate faster than eviction removes them. When WiredTiger cannot safely proceed without exceeding internal limits, it returns a WT_ROLLBACK error. The fix is not to retry blindly but to identify and shorten or chunk the slow operation that is holding the snapshot open.
Journaling and Checkpointing
Writes are applied to in-memory pages and appended to the write-ahead log (journal). By default, WiredTiger flushes the journal every 100 ms and runs a full checkpoint every 60 seconds or when the log reaches 2 GB. After a successful checkpoint, journal entries before that point are no longer needed for recovery.
With j: true in the write concern, MongoDB waits for the journal entry to be flushed to disk before acknowledging the write. This adds a small latency overhead (typically under 1 ms on NVMe storage) but guarantees that the write survives a crash. For financial or audit data this is non-negotiable. For high-throughput event ingestion where some loss is acceptable, omitting j: true roughly doubles write throughput on write-heavy workloads.
Without journaling enabled, a crash between checkpoints requires replaying the replica set oplog to recover, which is slower and may require initial sync if the divergence exceeds what the oplog covers.
The BSON Document Model
MongoDB stores documents as BSON, a binary encoding of JSON that adds explicit type information and is designed for efficient traversal. A BSON document opens with a 4-byte little-endian total length, followed by a sequence of typed field entries, and ends with a null byte. Each field entry is: a 1-byte type tag, the field name as a null-terminated C string, and the type-specific value encoding.
The length-prefix design lets MongoDB skip over entire sub-documents without deserializing them. Server-side field projection is efficient because the server can seek past excluded fields. Requesting only { userId: 1, status: 1, _id: 0 } causes the server to deserialize only those fields, reducing the bytes returned over the wire regardless of document size.
import { BSON } from "bson";
const doc = {
_id: new BSON.ObjectId(),
userId: "usr_01HX4K9M2P",
status: "active",
tags: ["payments", "enterprise"],
metadata: { plan: "pro", seats: 50 },
createdAt: new Date(),
};
const encoded = BSON.serialize(doc);
console.log(`BSON byte length: ${encoded.byteLength}`);
const decoded = BSON.deserialize(encoded);
console.log(decoded.metadata.plan); // "pro"
BSON supports types that JSON cannot represent: ObjectId (12-byte unique identifier), Date stored as milliseconds since epoch (not a string), Decimal128 for exact decimal arithmetic, and Binary for raw bytes. The choice of type matters. Storing a timestamp as a string means range queries depend on lexicographic ordering. Storing it as a BSON Date uses numeric comparison in the index, which is faster and immune to format inconsistencies.
Indexing Strategies
Compound Indexes and the ESR Rule
Secondary indexes in MongoDB are B-trees sorted by the compound key in the order fields are specified. Field order follows the Equality-Sort-Range (ESR) rule: fields used in equality predicates first, fields used in sort order second, fields used in range predicates last. An index that violates this ordering forces the planner to either scan more index entries than necessary or perform an in-memory sort.
// Query: find({ status: "active", tenantId: "t_01" }).sort({ createdAt: -1 }).where({ age: { $gt: 25 } })
// Correct ESR ordering: equality fields first, sort field second, range field last
await collection.createIndex(
{ status: 1, tenantId: 1, createdAt: -1, age: 1 },
{ name: "status_tenant_createdAt_age" }
);
Multikey Indexes
When an indexed field contains an array, MongoDB creates a multikey index that stores one index entry per array element. A compound index cannot have more than one field that is an array within the same document. Multikey indexes make queries into arrays fast because each element has its own entry in the B-tree, but they increase index write amplification: a document with an array of 50 elements generates 50 index entries on insert.
Text, Geospatial, and Wildcard Indexes
Text indexes tokenize string fields and store stemmed tokens with inverse document frequency scores. They support the $text operator with language-aware stemming and stop word filtering. A collection can have at most one text index. For production full-text search workloads, text indexes become a throughput bottleneck and a dedicated search engine is generally a better choice.
Geospatial 2dsphere indexes use a spherical earth model encoded as S2 cells. Point-within-polygon queries and proximity searches are executed as S2 cell set intersections, which are fast. The index entry for a polygon is the set of S2 cells covering it, so queries avoid per-document polygon containment tests.
Wildcard indexes index every field path in a document or subtree using a { "$**": 1 } pattern. They are useful when queries target arbitrary field paths in a schemaless collection. The cost is index size: each document produces one entry per indexed field, so index size grows proportionally with document width.
The Query Planner and Plan Cache
Plan Selection via Trial Period
For each new query shape (the combination of predicate fields, sort fields, and projection fields without the actual values), the planner generates candidate plans from all potentially applicable indexes. It runs these candidates concurrently for a short trial period: whichever plan reaches 101 returned documents or 101 examined index keys first wins. The winning plan is written to the plan cache keyed by the query shape.
Subsequent queries with the same shape skip the trial entirely and use the cached plan. The plan cache is invalidated when an index is added or dropped, when the collection is rebuilt, or when statistics show the cached plan’s performance has degraded significantly.
The trial period creates a subtle trap: a plan that looks efficient on the first 101 results can be a poor choice for queries that ultimately scan millions of documents. This is especially common when data is skewed and the early results happen to be clustered in a region of the index that is denser than average.
// Diagnose planner choices with executionStats
const explanation = await collection
.find({ status: "active", tenantId: "t_01" })
.sort({ createdAt: -1 })
.explain("executionStats");
const stats = explanation.executionStats;
console.log({
nReturned: stats.nReturned,
totalKeysExamined: stats.totalKeysExamined,
totalDocsExamined: stats.totalDocsExamined,
executionTimeMillis: stats.executionTimeMillis,
});
// A healthy query has totalKeysExamined close to nReturned.
// A ratio above 10:1 indicates poor index selectivity or wrong index choice.
Index Intersection
The planner can satisfy a query by intersecting two separate indexes: it scans both B-trees to collect RecordId sets and then computes their intersection in memory. Index intersection sounds appealing but is almost always slower than a well-designed compound index. The intersection step requires materializing two RecordId sets and joining them in memory, while a compound index provides the filtered, sorted result directly. If you find the planner choosing index intersection in explain output, the better fix is to build a compound index covering both predicates.
Covered Queries
A covered query satisfies the filter, sort, and projection entirely from index data, without reading the document from the collection B-tree. It is the fastest class of MongoDB query because it eliminates the second random-read step entirely.
// Create index covering filter (userId), sort (createdAt), and projected fields
await collection.createIndex({ userId: 1, createdAt: -1 });
// Covered query: all fields come from the index.
// _id must be explicitly excluded or included in the index,
// otherwise MongoDB fetches the document to return _id.
const results = await collection
.find(
{ userId: "usr_01HX4K9M2P" },
{ projection: { _id: 0, userId: 1, createdAt: 1 } }
)
.sort({ createdAt: -1 })
.toArray();
Use explain("executionStats") and verify that totalDocsExamined is zero for a covered query.
Replication: Oplog and Raft-Inspired Elections
Oplog Mechanics
Every write that modifies collection data is translated into one or more entries appended to the local.oplog.rs capped collection on the primary. The oplog stores operations in an idempotent form: an update using $inc is stored as the absolute new value, not the increment, so replaying it twice produces the same result. A findAndModify stores the full document before-image to enable rollback.
Each oplog entry carries a ts field of BSON type Timestamp: a 4-byte Unix timestamp combined with a 4-byte ordinal that makes sub-second entries unique and totally ordered.
Secondaries tail the primary’s oplog using a persistent cursor and apply entries in timestamp order. Replication is asynchronous by default: the primary acknowledges writes to the client before confirming that secondaries have applied the entry, unless the write concern requires majority acknowledgement.
The oplog is capped: it wraps around when it fills. The time span it covers determines how long a secondary can be offline before it must perform a full initial sync rather than catching up via oplog replay. At 200 MB/s of writes with a 50 GB oplog, the window is roughly 250 seconds. If a secondary restarts during a peak write period and the oplog has wrapped past the point it needs, it must clone all data from scratch.
// Change streams are built on the oplog. A resume token is a pointer into the oplog.
// If the token's timestamp is older than the oldest oplog entry, MongoDB throws
// ChangeStreamHistoryLostError and the consumer must perform a full resync.
const changeStream = collection.watch(
[{ $match: { "fullDocument.tenantId": "t_01" } }],
{
fullDocument: "updateLookup",
resumeAfter: lastResumeToken,
}
);
changeStream.on("change", async (event) => {
lastResumeToken = event._id; // persist this to durable storage on every event
await persistResumeToken(lastResumeToken);
await processEvent(event);
});
changeStream.on("error", async (err) => {
if ((err as any).code === 286) {
// ChangeStreamHistoryLostError: the resume token fell off the oplog window
await handleFullResync();
}
});
Every deployment using change streams must size the oplog to cover the maximum downtime of the change stream consumer, not just the maximum secondary lag.
Raft-Inspired Elections (Protocol Version 1)
MongoDB replica set elections use Protocol Version 1 (PV1), which is based on Raft but differs in meaningful ways.
Before calling a real election, a candidate runs a dry-run phase: it sends replSetRequestVotes with dryRun: true to check whether it could win without actually incrementing the term counter. If the dry run confirms a majority, it proceeds to a real election. This reduces split-vote scenarios compared to plain Raft by avoiding term counter churn from failed election attempts.
Key differences from textbook Raft:
- Members have configurable
priorityvalues (0 to 1000). A member with higher priority triggers an election to reclaim primary status once it catches up to within 10 seconds of the primary’s oplog position. - Members with
priority: 0can never become primary but still vote in elections. - Hidden members (
hidden: true) are invisible to clients inisMasterresponses, do not receive application reads, and cannot become primary regardless of priority. They participate in elections as voters. - A member only votes for a candidate whose oplog timestamp is at least as recent as its own last applied timestamp. This prevents a stale node from winning an election.
In practice: plan for 10 to 30 seconds of primary unavailability during an unplanned failover. Drivers configured with retryWrites: true will transparently retry idempotent write operations against the new primary after election completes.
Sharding Architecture
Chunks and the Balancer
Sharding distributes a collection’s data across multiple replica set “shards” by dividing the shard key value space into ranges called chunks. Each chunk is a half-open interval [minKey, maxKey) of shard key values and lives on exactly one shard.
When a chunk exceeds the target size (default 128 MB), the shard primary splits it at the median shard key value of documents within the chunk. The split creates two new chunk metadata entries in config.chunks on the config server but does not move any data. Splitting is cheap; it only updates metadata.
The config server’s balancer monitors chunk counts per shard. When the difference between the most-loaded and least-loaded shard exceeds the migration threshold, the balancer initiates a chunk migration:
- The destination shard clones the chunk’s documents from the source.
- The destination catches up with new writes by replaying the source shard’s oplog entries for the chunk range.
- The config server atomically updates the chunk ownership metadata.
- The source shard deletes the migrated documents in a deferred cleanup phase.
Migrations consume I/O and CPU on both shards and the config server. During migration, the chunk remains readable and writable on the source shard until the atomic metadata flip.
Shard Key Selection
The shard key is immutable after collection sharding (resharding is available since MongoDB 5.0 but is an expensive operation). A poor shard key choice has consequences that compound over time:
- Low-cardinality shard keys (boolean, status enum) produce a small number of chunks that cannot be split further, creating hotspots that cannot be rebalanced.
- Monotonically increasing shard keys (ObjectId, timestamp) concentrate all inserts at the shard holding the highest chunk, causing a write hotspot regardless of how many shards exist.
- The compound shard key
{ tenantId: 1, _id: 1 }distributes writes across shards proportionally to tenant activity and makes tenant-scoped queries targeted (routed to a single shard). It does create per-tenant hotspots for large tenants, which requires additional sharding strategies like tenant sub-sharding.
Hashed shard keys distribute inserts uniformly at the cost of making range queries scatter-gather across all shards.
Mongos Routing
The mongos process maintains a cached copy of the chunk map from the config server. A targeted query (filter includes the full shard key or its prefix) is routed to one shard. A scatter-gather query fans out to all shards and merges results on mongos. For latency-sensitive read paths, every query that can be targeted should include the shard key. Aggregation pipelines should have a $match on the shard key as the first stage to prevent scatter-gather.
Write Concerns and Read Concerns
import { MongoClient, WriteConcern } from "mongodb";
const client = new MongoClient(process.env.MONGO_URI!, {
maxPoolSize: 50,
minPoolSize: 5,
waitQueueTimeoutMS: 5000,
serverSelectionTimeoutMS: 3000,
});
// w: "majority" + j: true: durable on a majority of voting members,
// journal-flushed before acknowledgement. Required for financial writes.
const result = await collection.insertOne(
{ userId: "usr_01", event: "payment.completed", amount: 4999 },
{ writeConcern: new WriteConcern("majority", 5000, true) }
);
// readConcern: "local" - fastest, may return uncommitted data on a secondary
const localDoc = await collection.findOne(
{ userId: "usr_01" },
{ readConcern: { level: "local" } }
);
// readConcern: "majority" - returns only data durable on a majority
const durableDoc = await collection.findOne(
{ userId: "usr_01" },
{ readConcern: { level: "majority" } }
);
The interaction between write concern and read concern defines the consistency guarantee. With w: "majority" on writes and readConcern: "majority" on reads, you get linearizable read-your-writes semantics. With w: 1 and readConcern: "local", you may read a write that was later rolled back if the primary failed before replicating it.
readConcern: "snapshot" is available inside multi-document transactions. It provides a consistent point-in-time view of data, equivalent to snapshot isolation. readConcern: "linearizable" (for single-document reads) contacts a majority of nodes before returning, guaranteeing the most recently committed value at the cost of higher latency and no secondary routing.
Production Considerations
WiredTiger Cache Sizing
The WiredTiger cache is separate from the OS page cache. Its default size is max((RAM - 1 GB) / 2, 256 MB), so on a 32 GB instance the cache is 15.5 GB. When the working set (the pages accessed in steady state) fits in the cache, read latency is sub-millisecond. When it does not, eviction threads run continuously and I/O becomes the bottleneck.
Monitor wiredTiger.cache["pages read into cache"] from db.serverStatus(). A non-zero steady-state rate indicates cache pressure. The correct response is to increase instance size or expire old data with TTL indexes, not to increase the cache size beyond 60% of physical RAM (WiredTiger needs headroom for in-flight writes and update chains).
Connection Pooling
Each MongoClient instance manages a connection pool. In Node.js, creating one MongoClient per request is a common mistake that exhausts the server’s connection limit. Create one client per application process and reuse it.
let _client: MongoClient | null = null;
export async function getDb() {
if (!_client) {
_client = new MongoClient(process.env.MONGO_URI!, {
maxPoolSize: 50, // tune to application concurrency level
minPoolSize: 5,
connectTimeoutMS: 5000,
socketTimeoutMS: 30000,
});
await _client.connect();
}
return _client.db("appdb");
}
Each MongoDB connection consumes roughly 1 MB of RAM and a thread on the server. Setting maxPoolSize to an arbitrarily large number causes connection exhaustion before any query ever runs when many application pods connect simultaneously.
Oplog Window Sizing
Sizing the oplog to cover the right window is a critical capacity decision. The required window is:
oplogSizeGB = peakWriteRateMBps * windowHours * 3600 / 1024
At 100 MB/s peak writes with a desired 24-hour window: 100 * 24 * 3600 / 1024 = ~8.4 TB. That is impractical as a single capped collection. The realistic response is to measure the average write rate rather than peak, apply a 2x safety margin, and ensure change stream consumers checkpoint resume tokens frequently enough that a restart never needs more than a few minutes of oplog replay.
Chunk Migration Throttling
In MongoDB 4.4+, the balancer supports _configsvrSetClusterParameter with chunkMigrationConcurrency to limit concurrent migrations. During business hours, limiting to one or two concurrent migrations reduces I/O impact on production traffic. You can increase the limit during off-peak windows to rebalance faster after adding a shard. Chunk migrations on collections with large documents are proportionally more expensive because the clone phase copies raw document bytes, not index entries.
Tradeoffs Table
| Dimension | MongoDB | PostgreSQL | DynamoDB | CouchDB | Cassandra |
|---|---|---|---|---|---|
| Data model | BSON documents, schema-optional | Relational rows, strict schema (JSONB escape hatch) | Key-value items, flat structure | JSON documents, schema-free | Wide-column, sparse rows |
| ACID transactions | Multi-document ACID within a replica set or sharded cluster (4.0+) | Full ACID, mature and well-tested | Single-item ACID; cross-item transactions limited | Per-document optimistic concurrency (revision-based) | Lightweight transactions (compare-and-set only) |
| Horizontal write scaling | Native sharding, shard key selection is critical | Manual partitioning or Citus extension | Native, partition key defines scale, fully managed | Per-database replication, not natively sharded | Native wide-column partitioning, well-proven at scale |
| Query flexibility | Aggregation pipeline, ad-hoc filtering, text, geospatial | Full SQL, window functions, CTEs, rich join support | Filter expressions, secondary indexes (GSI/LSI) | Mango queries, MapReduce views (slow to build) | CQL (SQL-like), partition key required for most queries |
| Replication model | Async oplog replication, Raft-inspired elections | Streaming WAL, synchronous replication option | Fully managed, cross-region built-in | Multi-master with conflict resolution | Tunable consistency per operation (ONE/QUORUM/ALL) |
| Secondary indexes | Rich: compound, text, geospatial, wildcard, multikey | Rich: B-tree, GIN, GiST, BRIN, partial | Limited: GSI and LSI only | Views (MapReduce, slow to build initially) | Materialized views only |
| Operational complexity | Medium: replica sets manageable, sharded clusters complex | Low to medium: mature tooling and ecosystem | Very low: fully managed, no cluster to operate | Low to run, limited ecosystem and tooling | High: topology, compaction tuning, GC pauses, repairs |
| Sweet spot | Flexible document workloads, content, catalog, event storage, user profiles | Relational integrity, reporting, financial records, complex queries | Massive scale with simple and predictable access patterns | Offline-first sync, geographically distributed multi-master | Write-heavy time series, IoT telemetry, wide-column analytics |
Closing
MongoDB’s behavior under load is determined by a small number of early decisions: shard key, oplog size, write concern level, and index design. Getting any one of them wrong creates problems that are expensive to reverse once data is in production. The WiredTiger MVCC model, the plan cache’s trial-period competition, and the oplog’s dual role in replication and change streams are the internal mechanisms those decisions interact with most directly. Understanding them turns production failures from random events into predictable consequences of known design choices.
More in System Design
How Amazon Aurora Works Internally: The Log-Is-the-Database Architecture, Quorum Writes, and Storage-Compute Separation Behind Cloud-Native SQL
A deep dive into Aurora's storage-compute separation, the log-is-the-database design that pushes redo log processing to storage nodes, quorum-based replication across 6 copies in 3 AZs, protection group architecture, fast cloning, Serverless v2 scaling mechanics, and honest tradeoffs versus RDS, self-managed PostgreSQL, CockroachDB, and Cloud Spanner.
How CockroachDB Works Internally: Distributed SQL, Raft Consensus Per Range, and the Architecture Behind Serializable Transactions at Global Scale
A deep dive into CockroachDB's internals: the 512MB range-based data model with automatic splitting, per-range Raft replication, MVCC timestamp ordering with hybrid logical clocks, DistSQL physical planning, serializable transactions with timestamp refreshes, closed timestamps for follower reads, online schema changes using multi-version schema descriptors, and production considerations for hotspots and write amplification.
How Container Runtimes Work Internally: Namespaces, Cgroups, and the OCI Stack From Docker Run to Process Isolation
Containers are not virtual machines and they are not magic. They are a thin composition of Linux kernel primitives: namespaces, cgroups, and a layered filesystem. This article traces exactly what happens between docker run and a running process, covering the OCI spec, containerd, runc, overlay filesystems, and container networking at the kernel level.
How YugabyteDB Works Internally: DocDB Storage, Tablet Splitting, and the Dual API Architecture That Scales PostgreSQL Horizontally
A deep dive into YugabyteDB internals covering the DocDB storage engine built on RocksDB LSM trees, per-tablet Raft consensus, YSQL and YCQL dual query layers, distributed MVCC with hybrid logical clocks, automatic tablet splitting and rebalancing, xCluster multi-region replication, and production considerations for hotspots, connection pooling, and schema design.