A secondary comes back from a four-hour maintenance window and never returns to SECONDARY. It stays in RECOVERING, and its log says We are too stale to use <node>:27017 as a sync source. Or a change stream consumer restarts after a long weekend and fails with ChangeStreamHistoryLost. Both have one cause: the oplog window, the time between the oldest and newest oplog entries, was shorter than the gap. On self-managed MongoDB 7.0, 8.0, and 9.0, the default oplog is 5% of free disk space, capped at 50 GB, and a write-heavy workload can turn that into a few hours of history. This guide covers measuring the window and write rate, what fills the oplog, sizing with a worked example, resizing every member, and MongoDB Atlas.
- The oplog is a capped collection: its window is the configured size divided by your write rate, and a write burst shortens it.
- Measure the window with
rs.printReplicationInfo()on every member, and find what fills it by grouping the last hour oflocal.oplog.rsby operation and namespace. - Size for the longest of secondary downtime, a logical initial sync, a change stream consumer outage, or a
mongodump --oplogrun, plus a margin. - Resize with
replSetResizeOplogon each secondary, then the primary, without a restart. Put the same values inmongod.conf. - Atlas does not support
replSetResizeOplog. Atlas Core clusters with storage auto-scaling keep a minimum oplog window of 24 hours by default.
Scope and review date: November 2026. This post covers self-managed MongoDB 7.0, 8.0, and 9.0 replica sets, including each shard of a sharded cluster, with a section on MongoDB Atlas. Commands, defaults, and limits were checked against the MongoDB 9.0 manual, the matching 7.0 pages, and the Atlas documentation on 9 November 2026. They were not run in a lab. The terminal output and every number in the worked example are illustrative.
What is the oplog window, and why does it shrink?
The replica set oplog is a capped collection, local.oplog.rs, on every member. Each write that changes data appends entries, and secondaries copy and apply them in order. When the oplog reaches its maximum size, MongoDB deletes the oldest entries.
The oplog window is the time between the newest and oldest entries. A secondary that loses contact with its sync source can catch up only if it returns inside that window. Because the size is fixed in bytes, the window depends on how fast you write: a 50 GB oplog holds about 50 hours at 1 GB per hour and about 5 hours at 10 GB per hour.
The oplog as a capped window, and a secondary inside it
Writes enter the window, truncation removes the oldest entries, and the secondary must stay in between
Watch headroom, not lag alone: a secondary 2 hours behind is safe inside a 48-hour window and lost inside a 90-minute one. Atlas reports this difference as Replication Headroom.
Two details matter for sizing. The configured size is the uncompressed size of the entries, not bytes on disk (replication.oplogSizeMB). And the oplog can grow past its size so that it never deletes the majority commit point, so a lagging majority can temporarily make it larger than you set.
How big is the MongoDB oplog by default?
A member sets the default when it first creates its oplog.
| Platform and engine | Default oplog size |
|---|---|
| Linux or Windows, WiredTiger | 5% of free disk space, at least 990 MB, at most 50 GB |
| Linux or Windows, in-memory engine | 5% of physical memory, at least 990 MB, at most 50 GB |
| 64-bit macOS | 192 MB of free disk space (or of memory for the in-memory engine) |
The 50 GB cap applies only to the default: replSetResizeOplog accepts 990 MB up to 1 PB.
// mongosh, connected directly to one member
const s = db.getSiblingDB("local").oplog.rs.stats(1024 * 1024)
({ maxSizeMB: s.maxSize, usedMB: s.size, onDiskMB: s.storageSize })Interpretation: maxSizeMB is the configured maximum, usedMB the uncompressed size of the current entries, and onDiskMB the compressed size on disk. If usedMB is below maxSizeMB, the oplog is not full yet and its window only reflects the member's age.
How do I measure the oplog window and the write rate?
rs.printReplicationInfo() prints the window for people. In scripts, use db.getReplicationInfo(), which returns a document. Both read the member you are connected to.
rs.printReplicationInfo() on a primary with the default 50 GB oplog
Illustrative output, shaped like mongosh: the oplog is full and holds 8.5 hours
8.5 hours is history, not a forecast: it measures oldest entry to newest, so the window right after a burst is the one that counts. Field meanings are in the db.getReplicationInfo() reference.
In current mongosh, actual oplog size is the larger of the configured and real sizes, so a value above configured oplog size means the oplog has grown past its limit. Once the oplog is full, the used size divided by the window gives the average write rate:
// Average oplog write rate over the current window (oplog must be full)
const r = db.getReplicationInfo()
({ usedMB: r.usedMB, windowHours: r.timeDiffHours,
mbPerHour: Math.round(r.usedMB / r.timeDiffHours) })Interpretation: for the output above, about 6,000 MB per hour. An average hides bursts, so measure hour by hour next.
What fills the oplog faster than you expect?
Oplog volume follows the number of changed documents, not data growth. The oplog documentation names updates to many documents at once, deletes that match inserts, and many in-place updates. In each case the data barely changes while the oplog fills.
- Bulk updates. The oplog turns a multi-document update into one entry per document, so an
updateManyover 10 million documents writes 10 million entries. - Multi-document transactions. A transaction writes as many oplog entries as it needs, each within the 16 MB BSON limit (transactions in production).
- TTL and purge deletes. Only the primary runs TTL deletes, and secondaries replicate each one. A new TTL index on a large collection, or a lower
expireAfterSeconds, can expire many documents at once (TTL indexes). - Bulk loads. Large ingests with unacknowledged writes can outrun the secondaries.
- Index builds. A replicated build writes only small
startIndexBuildandcommitIndexBuildentries. The risk is time: a rolling build takes a member out, and the rolling build procedure says the oplog must be large enough for it to catch up.
// Top writers in the last hour, by operation and namespace.
// Reads every entry of that hour: run it on a secondary, off peak.
db.getMongo().setReadPref("secondaryPreferred")
const since = Math.floor(Date.now() / 1000) - 3600
db.getSiblingDB("local").oplog.rs.aggregate([
{ $match: { ts: { $gte: Timestamp({ t: since, i: 0 }) } } },
{ $group: {
_id: { op: "$op", ns: "$ns" },
entries: { $sum: 1 },
mb: { $sum: { $divide: [ { $bsonSize: "$$ROOT" }, 1048576 ] } }
} },
{ $sort: { mb: -1 } },
{ $limit: 10 }
])Interpretation: op is i, u, d, c (command), or n (no-op). Transactions appear as c entries on admin.$cmd that wrap the real operations. The hourly mb total is your churn rate; run it for your busiest hour, such as the nightly batch.
What happens when the oplog window is too short?
Secondaries fall off and need an initial sync
If a secondary's next entry is gone from its sync source, it cannot catch up. It enters RECOVERING and needs manual intervention (replica set states), and logs the "too stale" line from the introduction, as MongoDB's oplog alert guide shows. The fix is a full resync.
The initial sync needs the window too. A logical initial sync clones the data and then applies the entries written meanwhile. If it finds a gap, it restarts from the beginning (replica set data synchronization), so a window shorter than your initial sync time can loop.
Change streams lose their resume point
Resuming with resumeAfter, startAfter, or a past startAtOperationTime needs that point to still be in the oplog (change streams). Otherwise the server returns ChangeStreamHistoryLost, code 286 in the server's error code list. An open Jira report, SERVER-128753, shows 8.0.18 returning ChangeStreamFatalError (280) in a similar case, so check the oplog's first event time before trusting the code. Our change streams production guide covers storing resume tokens and recovering from this error.
Backups and rolling maintenance overrun
mongodump --oplog captures entries written during the dump, and the replSetResizeOplog reference warns that truncation can make it miss entries. Every member taken offline for maintenance must also rejoin inside the window.
// On the primary: lag and headroom for each secondary
const info = db.getReplicationInfo()
const st = rs.status()
const p = st.members.find(m => m.stateStr === "PRIMARY")
st.members.filter(m => m.stateStr === "SECONDARY").map(m => {
const lagH = (p.optimeDate - m.optimeDate) / 3600000
return { member: m.name, syncsFrom: m.syncSourceHost,
lagHours: lagH.toFixed(2),
headroomHours: (info.timeDiffHours - lagH).toFixed(2) }
})Interpretation: this uses the primary's window. A secondary that syncs from another secondary (syncsFrom) depends on that member's window, so measure it there too.
How do I size the oplog from my write rate?
First decide how many hours you need, then find the most oplog data your workload writes in any stretch of that length. MongoDB's replica set troubleshooting guide says the oplog should cover the longest downtime you expect on a secondary and at a minimum 24 hours of operations, and that many users prefer 72 hours or a week.
The example continues the cluster from the terminal card. All numbers are illustrative arithmetic, not measurements.
| Requirement | Hours (illustrative) |
|---|---|
| Longest planned secondary outage | 6 |
Longest mongodump --oplog run | 5 |
| Logical initial sync, timed on staging | 20 |
| Longest change stream consumer outage to survive | 36 |
| Target: largest requirement x 1.5 | 54 |
The hourly aggregation shows a steady 4 GB per hour plus a nightly job from 02:00 to 04:00 UTC at 12 GB per hour. That explains the 8.5-hour window: 1.5 hours before the job (6 GB), the job (24 GB), and 5 hours after it (20 GB) fill 50 GB.
| Step | Arithmetic (illustrative) | Result |
|---|---|---|
| Baseline over 54 hours | 4 GB/h x 54 h | 216 GB |
| Extra per nightly run | (12 - 4) GB/h x 2 h | 16 GB |
| Worst 54-hour stretch (3 runs) | 216 + 3 x 16 | 264 GB |
| Size to set, rounded up | 270 GB x 1024 | 276,480 MB |
The 1.5 margin is our convention, not a MongoDB rule. Because the size is uncompressed, the oplog usually takes less disk than its size; compare storageSize with size for your ratio, but plan free space without compression until you have measured it.
Batching a job lowers its peak rate and lag, not the total it writes. To write less, change the job: dropping a collection writes one oplog entry, while deleting its documents writes one per document, so a monthly purge can drop a per-month collection instead.
Should I use a fixed size or a minimum retention period?
You can also set oplogMinRetentionHours. The mongod then deletes an entry only when the oplog is at its maximum size and the entry is older than that many hours by the host clock. The default is 0: size alone decides.
Size-based truncation vs a minimum retention period
Switch modes to see what bounds the window and what bounds the disk
Size only (default)
| Question | Size only |
|---|---|
| What you set | Maximum size in MB |
| When an entry is deleted | When the oplog reaches its size, oldest first |
| Window during a write burst | Shrinks, sometimes from days to hours |
| Disk use | Bounded by the size, apart from the majority commit point |
| Self-managed setting | oplogSizeMB, or replSetResizeOplog with size |
| Atlas Core equivalent | Set Oplog Size, only with storage auto-scaling off |
| Main risk | The window collapses and secondaries go stale |
Size plus minimum retention
| Question | Size plus minimum retention |
|---|---|
| What you set | Maximum size in MB and a minimum number of hours |
| When an entry is deleted | Only when the size is reached and the entry is older than the hours |
| Window during a write burst | Stays at least the configured hours |
| Disk use | Can grow without a limit and may exhaust the disk |
| Self-managed setting | oplogMinRetentionHours, or replSetResizeOplog with minRetentionHours |
| Atlas Core equivalent | Set Minimum Oplog Window, 24 hours by default with storage auto-scaling |
| Main risk | A burst fills the disk; clock drift between members changes what is kept |
Trade-off: a retention period moves the risk from replication to disk space. Use it only with a disk-space alert, and size the oplog so retention rarely has to override it.
The configuration reference adds that once retention has grown the oplog past its size, the mongod may keep that disk space afterwards. For the example, the 270 GB size plus a 36-hour minimum (the longest single requirement) is a reasonable pair.
// Current minimum retention; an empty result means none is set
db.getSiblingDB("admin").serverStatus().oplogTruncation.oplogMinRetentionHoursInterpretation: a value set at runtime with replSetResizeOplog overrides the startup setting until the next restart.
How do I resize the oplog safely on every member?
replSetResizeOplog changes only the member you run it on. The change oplog size procedure runs it on each secondary, then the primary, with no restart, as a user with a role such as clusterManager or clusterAdmin.
Resize the oplog on a three-member replica set
Eight steps; step 5 briefly locks the primary's oplog, and step 8 is only for shrinking
-
Record the size and window on every member
Connect to each member directly and keep the numbers in the change ticket.
db.getSiblingDB("local").oplog.rs .stats(1024 * 1024).maxSize rs.printReplicationInfo() -
Check free disk on every member
Plan for the full uncompressed size until you have measured the on-disk ratio.
df -h /var/lib/mongo -
Resize the first secondary
The size is in megabytes and must be a double, so cast it with Double() in mongosh.
db.adminCommand({ replSetResizeOplog: 1, size: Double(276480) }) -
Resize the other secondaries
One at a time, including hidden and delayed members. Confirm maxSize on each.
-
Resize the primary last
Brief oplog lockThe command holds an exclusive lock on the oplog, blocking new writes, until it finishes. Run it in a quiet minute.
db.adminCommand({ replSetResizeOplog: 1, size: Double(276480) }) -
Set a minimum retention period, if you use one
Also per member, and only with a disk-space alert in place.
db.adminCommand({ replSetResizeOplog: 1, minRetentionHours: 36 }) -
Write the values into mongod.conf
A resynced member creates its oplog from oplogSizeMB, and a runtime retention value is lost at restart without the file setting.
-
Shrinking only: compact oplog.rs on a secondary
Member stops syncingA smaller size does not return disk space. Compact blocks oplog sync on that member, so never run it on the primary.
db.getSiblingDB("local") .runCommand({ compact: "oplog.rs" })
Rollback point: growing the oplog can be undone with another resize. Shrinking deletes the oldest entries at once, which can invalidate change streams and leave syncing members stale, and nothing brings them back.
# /etc/mongod.conf excerpt after the change
replication:
replSetName: rs0
oplogSizeMB: 276480
storage:
dbPath: /var/lib/mongo
oplogMinRetentionHours: 36Interpretation: oplogSizeMB does nothing on a member that already has an oplog; it sizes the next member you build or resync, which would otherwise get the 5% default. Keep sizes equal on all members, and on a sharded cluster repeat this on every shard and the config server replica set.
How does oplog sizing work on MongoDB Atlas?
Atlas does not support replSetResizeOplog. As of November 2026, Atlas additional settings and Atlas storage describe three cases.
- Atlas Core with storage auto-scaling (the default). Atlas manages the oplog by a minimum oplog window (
oplogMinRetentionHours), 24 hours by default. Atlas states that storage auto-scaling needs a window of up to 60 seconds per GB of configured disk, about 8.3 hours on 500 GB, and alerts when scaling is blocked by insufficient oplog size. - Atlas Core with a fixed size. Set Oplog Size needs storage auto-scaling off and the minimum window at 0. The minimum is 990 MB, uncompressed, and Atlas rejects a size that leaves less than 25% of the disk free.
- Atlas Infinite (public preview). Replication runs in the storage layer, so nodes cannot fall behind the oplog window. The oplog still serves change streams, and its retention sets how far back they can resume. You cannot set its size.
# Atlas CLI: 48-hour minimum oplog window on an Atlas Core cluster (M10+)
atlas clusters advancedSettings update Cluster0 \
--projectId <projectId> \
--oplogMinRetentionHours 48Interpretation: confirm the change in the Replication Oplog Window chart. Atlas alerts on Replication Oplog Window, Oplog Data Per Hour, and Replication Headroom, and its alert guide suggests an oplog above the peak of the Oplog GB / Hour chart.
What should I alert on?
These thresholds are our starting points, not MongoDB defaults.
| Signal | Source | Suggested threshold |
|---|---|---|
| Oplog window, every member | timeDiffHours | Warn below the target; page below the longest single requirement |
| Headroom per secondary | Source window minus lag | Page below 25% of the target |
| Replication lag | rs.status() optimes | Warn above 60 seconds for 5 minutes |
| Oplog write rate | Hourly aggregation or Oplog GB / Hour | Warn at twice the usual peak hour |
| Member state | stateStr | Page if RECOVERING or STARTUP2 for 15 minutes |
| Free disk with retention set | Filesystem | Warn below 25% free |
// One line per run for a monitoring agent, on every member
const r = db.getReplicationInfo()
printjson({ host: db.hello().me, windowHours: r.timeDiffHours,
usedMB: r.usedMB, logSizeMB: r.logSizeMB })Interpretation: each member has its own window, so collect from all of them. Do not shrink the oplog to free disk: only the oplog can reuse that space. Add disk instead.
Frequently asked questions
How long should a MongoDB oplog window be?
Long enough to cover your longest secondary downtime, logical initial sync, change stream consumer outage, and mongodump run with the oplog option, plus a margin. MongoDB's troubleshooting guide says at a minimum 24 hours of operations, and notes that many users prefer 72 hours or a week. Atlas Core clusters with storage auto-scaling keep 24 hours by default.
Does replSetResizeOplog need a restart?
No. replSetResizeOplog resizes the oplog of a running member, and the new size persists after a restart. It affects only the member you run it on, so run it on each secondary and then the primary. It holds an exclusive lock on the oplog while it runs, and MongoDB Atlas does not support it.
What happens when a secondary falls off the oplog?
The entry it needs next has been deleted on its sync source, so it cannot catch up by replication. The member enters RECOVERING, logs that it is too stale to use the sync source, and needs manual intervention: a resync by initial sync from an empty data directory, or from a recent copy of another member's data files.
Key takeaways
- The oplog window is size divided by write rate. Measure both on every member, using the busiest hour, not the average.
- Bulk updates, transactions, TTL and purge deletes, and bulk loads fill the oplog without growing your data.
- Set the target window from your longest requirement, then size for the worst stretch of that length.
- Resize secondaries first and the primary last, keep sizes equal, and write the values into
mongod.conf. - A minimum retention period protects the window but not the disk. On Atlas, set the minimum oplog window instead.
Size your MongoDB oplog with JusDB
JusDB's MongoDB performance tuning team can measure your oplog window and write churn on every member, find the jobs that fill it, and plan a resize and alert thresholds that match your maintenance, initial sync, and change stream needs.
Bring your topology, versions, current rs.printReplicationInfo() output, and your batch job schedules. Your team approves every production change.