How MongoDB Works: The Document Model, WiredTiger Storage Engine, and Replication From Write Path to Oplog
A deep dive into MongoDB internals covering the BSON document model, WiredTiger storage engine with B-tree indexes and cache management, the full write path from driver to journal to data files, oplog-based replication with elections and read preferences, sharding with chunk migration, and the aggregation pipeline execution model.
MongoDB is one of those databases that accumulates strong opinions quickly. Engineers who hit its document model at the right moment love it. Engineers who reach for it when a relational model would have served better end up fighting the flexibility they thought they were getting. Understanding the internals helps you make that call correctly and operate it well once the choice is made.
This covers the machinery underneath: the BSON document model, the WiredTiger storage engine, the write path from driver call to fsync, oplog-based replication, sharding, and the aggregation pipeline. No opinions on whether you should use it. Just how it works.
The BSON Document Model
MongoDB stores data as BSON (Binary JSON). The format is a superset of JSON: it adds types not native to JSON including Date, ObjectId, Decimal128, BinData, Timestamp, and Regex. Each BSON document is a length-prefixed byte sequence where each element carries its type tag, key name as a C string, and value.
// What you write in a driver maps directly to a BSON document
interface OrderDocument {
_id: ObjectId;
customerId: ObjectId;
createdAt: Date;
status: "pending" | "fulfilled" | "cancelled";
items: Array<{
sku: string;
qty: number;
unitPrice: Decimal128;
}>;
shippingAddress: {
street: string;
city: string;
country: string;
};
total: Decimal128;
}
The document is self-describing. Every field carries its type at the byte level. This matters for indexing: MongoDB does not need a schema to decode a field value when building or traversing an index.
Schema Validation
“Schema-less” is the wrong framing. What MongoDB gives you is optional enforcement. You can attach a $jsonSchema validator to any collection, enforcing field types, required fields, enum values, and nested structure. Production collections should use this.
import { MongoClient } from "mongodb";
const client = new MongoClient(process.env.MONGO_URI!);
const db = client.db("commerce");
await db.createCollection("orders", {
validator: {
$jsonSchema: {
bsonType: "object",
required: ["customerId", "createdAt", "status", "items", "total"],
properties: {
customerId: { bsonType: "objectId" },
createdAt: { bsonType: "date" },
status: {
bsonType: "string",
enum: ["pending", "fulfilled", "cancelled"],
},
items: {
bsonType: "array",
minItems: 1,
items: {
bsonType: "object",
required: ["sku", "qty", "unitPrice"],
properties: {
sku: { bsonType: "string" },
qty: { bsonType: "int", minimum: 1 },
unitPrice: { bsonType: "decimal" },
},
},
},
total: { bsonType: "decimal" },
},
},
},
validationAction: "error",
validationLevel: "strict",
});
validationAction: "error" rejects non-conforming documents. validationAction: "warn" lets them through but logs. Use "error" in production.
WiredTiger Storage Engine
WiredTiger became MongoDB’s default storage engine in version 3.2. Before that the default was MMAPv1, which is worth knowing only so you understand why 3.2 was a significant operational change.
B-Tree Index Structure
WiredTiger stores collection data and each index as separate B-tree files on disk. The collection itself is stored as a B-tree keyed on RecordId (an internal 64-bit identifier). Index B-trees map key values to the RecordId of the matching document. When you do a read by index, WiredTiger first traverses the index B-tree to get the RecordId, then does a second lookup into the collection B-tree to fetch the document. This is the index scan plus fetch pattern. For a covering index (all projected fields present in the index), the second lookup is skipped.
// A covering index for this query avoids the document fetch entirely
const collection = db.collection<OrderDocument>("orders");
// Index on { customerId: 1, status: 1, createdAt: 1 }
await collection.createIndex(
{ customerId: 1, status: 1, createdAt: 1 },
{ name: "idx_customer_status_date" }
);
// This query is covered: all fields used are in the index
const recentPendingOrders = await collection
.find(
{ customerId: targetId, status: "pending" },
{ projection: { _id: 0, createdAt: 1, status: 1 } }
)
.sort({ createdAt: -1 })
.toArray();
Compression
WiredTiger compresses both data files and indexes. The default compressor for collection data is Snappy (fast, moderate ratio). zstd is available and preferred for archival workloads where compression ratio matters more than compression CPU cost. Indexes default to prefix compression: common key prefixes are factored out across adjacent B-tree pages.
Compression operates at the page level, not the document level. Pages are decompressed into the cache before reads and compressed before writing to disk.
Cache Management
WiredTiger maintains an in-memory cache distinct from the OS page cache. By default the cache is sized at the larger of 256 MB or half of available RAM minus 1 GB. Both caches are in play: WiredTiger’s cache holds uncompressed, ready-to-query pages. Pages that are evicted from the WiredTiger cache fall back to OS page cache as compressed bytes on disk.
The WiredTiger cache uses a clock-sweep eviction algorithm, similar to how PostgreSQL’s buffer manager works. When the cache is under pressure, a background eviction pass marks and sweeps candidate pages. If foreground threads are blocked waiting for cache pages, you have an eviction problem. The diagnostic signal is the cache eviction worker threads evicting pages metric in db.serverStatus().wiredTiger.cache.
// Check WiredTiger cache pressure at runtime
const status = await db.command({ serverStatus: 1 });
const cache = status.wiredTiger.cache;
console.log({
cacheSizeBytes: cache["maximum bytes configured"],
usedBytes: cache["bytes currently in the cache"],
dirtyBytes: cache["tracked dirty bytes in the cache"],
evictionPressure:
cache["pages evicted by application threads"] > 0
? "HIGH: application threads are evicting"
: "normal",
});
Dirty pages (modified but not yet written to disk) have a separate threshold. When dirty bytes exceed ~5% of cache size, background eviction threads start flushing. At 20% dirty, application threads start helping with eviction. This is the “write stall” pattern that shows up under sustained heavy write load.
The Write Path: Driver to Data File
Tracing a single insertOne call through the stack makes the durability guarantees concrete.
1. Driver Serialization
The driver serializes your JavaScript object to BSON bytes and sends them to mongod over a TCP connection using the MongoDB Wire Protocol (OP_MSG framing). The writeConcern is attached to the message: { w: 1 } means wait for the primary’s acknowledgment, { w: "majority" } means wait for a majority of replica set members to confirm.
2. Command Processing
The mongod process receives the OP_MSG, parses it, and routes to the write command handler. Validation against any $jsonSchema validator runs here, before any storage layer is touched. If validation fails, the error is returned immediately.
3. Write to WiredTiger Journal (WAL)
Before touching the collection B-tree, WiredTiger writes the operation to its write-ahead log (the journal). Journal records are grouped into 128-byte aligned log records and appended to the journal file. fsync of the journal file happens at 50ms intervals by default (storage.journal.commitIntervalMs). If the server crashes between two journal syncs, the last 50ms of acknowledged writes can be lost unless writeConcern: { j: true } was specified, which forces an fsync before the acknowledgment is sent.
4. In-Memory Update
After journaling, WiredTiger applies the change to the relevant B-tree pages in the cache. The change is now visible to subsequent reads on this node. The pages are marked dirty.
5. Checkpoint
WiredTiger writes a consistent checkpoint to disk every 60 seconds (storage.syncPeriodSecs). A checkpoint is a complete consistent snapshot of the data files. On startup after a clean shutdown, MongoDB starts from the last checkpoint. On startup after a crash, it starts from the last checkpoint and replays journal records written after that checkpoint.
6. Write Concern Acknowledgment
With { w: 1 }, the acknowledgment is sent after the journal write (or after the in-memory write if journaling is disabled). With { w: "majority" }, the acknowledgment is held until the replication subsystem confirms that a majority of voting members have applied the write to their oplogs.
Oplog-Based Replication
A MongoDB replica set is a group of mongod instances that maintain the same data set. One node holds the primary role and accepts writes. The others are secondaries that replicate from the primary.
The Oplog
The oplog (local.oplog.rs) is a capped collection on every replica set member. The primary appends every write operation to the oplog as an idempotent operation entry. Each entry has a timestamp (ts field using the Timestamp BSON type, which encodes seconds and an ordinal counter for sub-second ordering) and an operation descriptor.
Example oplog entries for an insert and an update:
// Insert
{ op: "i", ns: "commerce.orders", ts: Timestamp(1716470000, 1),
o: { _id: ObjectId("..."), customerId: ObjectId("..."), status: "pending", ... } }
// Update
{ op: "u", ns: "commerce.orders", ts: Timestamp(1716470001, 1),
o: { $v: 2, diff: { u: { status: "fulfilled" } } },
o2: { _id: ObjectId("...") } }
The $v: 2 diff format (introduced in MongoDB 4.4) avoids storing the full replacement document for updates, reducing oplog size significantly for updates to large documents.
The oplog is sized either as a fixed byte limit (replication.oplogSizeMB) or, on newer versions, as a minimum retention window (replication.oplogMinRetentionHours). If secondaries fall too far behind and the oplog has wrapped around, they enter RECOVERING state and require a full initial sync. Monitoring oplog window headroom is a production-critical metric.
Replication Flow
Secondaries run a replication thread that tails the primary’s oplog using a change stream cursor. Each pulled batch of oplog entries is applied in parallel by multiple applier threads (each thread owns a subset of namespace-level locks to maintain intra-document ordering). After applying, entries are appended to the secondary’s own oplog.
// Read preference routing in the Node.js driver
import { MongoClient, ReadPreference } from "mongodb";
const client = new MongoClient(process.env.MONGO_URI!, {
readPreference: ReadPreference.SECONDARY_PREFERRED,
});
// This read will go to a secondary if one is available and caught up
const orders = await client
.db("commerce")
.collection<OrderDocument>("orders")
.find({ status: "pending" })
.toArray();
secondaryPreferred reads are eventually consistent: a secondary may lag behind the primary. For reads that require the most recent committed data, use primary or primaryPreferred read preference, or use readConcern: { level: "majority" } which ensures only majority-committed data is returned.
Elections
When the primary becomes unreachable, eligible secondaries hold an election using the Raft-inspired consensus protocol. Each member has a priority (default 1). Members with priority 0 can never become primary (useful for analytics replicas or delayed replicas). A member calls an election by incrementing its term, voting for itself, and requesting votes from other members. The candidate with the most up-to-date oplog (highest ts) and sufficient votes wins. Elections typically complete in under 12 seconds, bounded by the electionTimeoutMillis setting (default 10 seconds).
Sharding
Sharding distributes data across multiple replica sets (shards). The cluster consists of shards (each a replica set), config servers (a replica set that stores cluster metadata), and mongos routers (stateless query routing processes).
Chunk-Based Distribution
Each sharded collection has a shard key. The key space is divided into chunks, each representing a range of shard key values. The balancer (running on the config server primary) monitors chunk counts per shard and migrates chunks to equalize distribution. A chunk migration copies data from source to destination shard, then atomically updates the chunk ownership record in the config server.
// Choosing a shard key: avoid monotonic keys (ObjectId, Date) as the sole shard key.
// They funnel all new writes to a single "hot" shard.
// Hash-based sharding distributes writes uniformly but loses range locality.
// Range shard key — good for range queries, bad if monotonic writes dominate
await db.command({
shardCollection: "commerce.orders",
key: { customerId: 1, createdAt: 1 },
});
// Hashed shard key — uniform write distribution, range queries hit all shards
await db.command({
shardCollection: "commerce.orders",
key: { _id: "hashed" },
});
Zone-based sharding lets you pin ranges of shard key values to specific shards. This is useful for data residency requirements (EU customer data on EU shards) or for pinning hot SKUs to higher-spec hardware.
Aggregation Pipeline
The aggregation pipeline is MongoDB’s query execution model for analytical and transformation workloads. A pipeline is an ordered list of stages. Each stage receives a stream of documents and emits a transformed stream.
// Aggregation pipeline: revenue by status for a customer, last 30 days
const pipeline = [
{
$match: {
customerId: targetId,
createdAt: { $gte: new Date(Date.now() - 30 * 86400 * 1000) },
},
},
{
$group: {
_id: "$status",
orderCount: { $sum: 1 },
totalRevenue: { $sum: "$total" },
},
},
{
$sort: { totalRevenue: -1 },
},
{
$project: {
_id: 0,
status: "$_id",
orderCount: 1,
totalRevenue: { $toString: "$totalRevenue" },
},
},
];
const result = await collection.aggregate(pipeline).toArray();
Execution planning for aggregations follows the same cost-based optimizer as find queries. The query planner can push $match and $sort stages down to use indexes before any document-level transformation occurs. The explain("executionStats") output on an aggregation shows whether index pushdown happened and where the COLLSCAN appears.
$lookup (left outer join to another collection) is the stage most likely to cause performance issues. It executes as a nested loop by default: for each document from the outer collection, a separate query runs against the joined collection. An index on the joined field in the foreign collection is mandatory in production.
Tradeoffs
| Dimension | MongoDB | PostgreSQL | DynamoDB | CouchDB | Cassandra |
|---|---|---|---|---|---|
| Data model | Flexible BSON documents, optional schema | Strict relational schema | Key-value with optional LSI/GSI | JSON documents, eventually consistent | Wide-column, partition-key model |
| Query flexibility | Rich query language, aggregation pipeline | Full SQL, CTEs, window functions | Limited: primary key and secondary indexes only | MapReduce or Mango queries | CQL (SQL-like but partition-key constrained) |
| Horizontal write scaling | Sharding (requires upfront key design) | Limited (Citus ext, read replicas only natively) | Native, automatic, transparent | Multi-master optional (CouchDB sync) | Native, masterless, linear scale |
| Consistency | Tunable per-operation (w, j, readConcern) | Serializable by default | Eventually consistent (configurable with transactions) | Eventual by default | Tunable (ONE, QUORUM, ALL) |
| Multi-document transactions | Yes (since 4.0, replica set; 4.2, sharded) | Yes, ACID | Yes (up to 100 items, within account) | No native transactions | Lightweight transactions (LWT) only |
| Operational complexity | Medium: replica sets, oplog monitoring, shard key selection | Low to medium: mature tooling, pg_bouncer, PITR | Low: fully managed | Low: simple replication setup | High: tuning, compaction, token ring |
| Schema migration | In-place, additive easy, index rebuilds costly | ALTER TABLE with lock or pg_repack | No schema, but index changes are online | No schema enforcement | ALTER TABLE non-blocking for adding columns |
Production Considerations
Oplog sizing. The default oplog size (5% of disk, capped at 50 GB) works until it does not. A secondary that restarts during a heavy migration or bulk write can find the oplog has wrapped around, requiring a full resync. Size the oplog to cover at least twice your longest expected maintenance window, or set oplogMinRetentionHours.
Index builds. Since MongoDB 4.4, index builds use a hybrid approach: a background build that holds an intent lock rather than an exclusive collection lock during the main scan, but acquires an exclusive lock to finalize. On large collections (50M+ documents), the finalization lock still causes noticeable replication lag. Build indexes during low-traffic windows and monitor secondary lag with rs.printSecondaryReplicationInfo().
Write concern defaults. The driver default of { w: 1 } means acknowledged by the primary’s in-memory write, not by the journal. For durability across primary failure, use { w: "majority", j: true }. The latency cost is real (adds one network round-trip to secondaries), but the durability guarantee is worth it for anything you care about.
Shard key immutability. MongoDB 4.2+ allows shard key field updates in some configurations, but shard key changes are operationally expensive. The wrong shard key is effectively permanent. Spend time on this decision before the collection grows. A monotonically increasing shard key (ObjectId alone, Date alone) will create a write hotspot on the last chunk shard regardless of cluster size.
Connection pooling. The MongoDB Node.js driver maintains a connection pool per mongos/mongod. The default pool size is 5. Under high concurrency, exhausting the pool causes queuing in the driver. Set maxPoolSize based on your expected concurrent operations and mongod’s maxIncomingConnections limit. Atlas M10 and M20 tiers have low connection limits; this surprises teams that migrate from self-hosted.
Aggregation memory. $group, $sort, and $bucket stages buffer documents in memory. The default per-stage limit is 100 MB. For large aggregations, set { allowDiskUse: true }. This spills to disk and is significantly slower, but it prevents the aggregation from being killed. The real fix is to add a $match early in the pipeline so fewer documents reach the heavy stages.
How the Pieces Fit Together
A write lands at the driver, gets serialized to BSON, passes schema validation, hits the WiredTiger journal for durability, modifies B-tree pages in cache, gets appended to the oplog, and eventually reaches secondaries that replay it against their own WiredTiger instances. Reads go through the same B-tree lookup path, with the index determining whether a full collection scan or a targeted key lookup happens. The aggregation pipeline sits on top of this, using the same indexes and the same executor, adding transformation and grouping stages that the find interface cannot express.
The flexibility is real, but so is the operational surface area. Shard key selection, oplog sizing, write concern configuration, and index build timing each require deliberate attention. Teams that treat MongoDB as a black box that scales automatically tend to hit the same walls: hotspot shards, replication lag, and aggregations that silently degrade as collections grow. The teams that operate it well know which metrics to watch and which knobs to turn before the alerts fire.
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.