JusDB
Free DB Audit
NoSQL Databases

MongoDB Change Streams in Production: Resume Tokens and the Oplog Window

Run MongoDB change streams in production: store resume tokens durably, size the oplog window, recover from ChangeStreamHistoryLost, and choose updateLookup or pre-images.

JusDB Team
Published October 25, 2026
Updated November 9, 2026
19 min read

A change stream consumer that has run for months stops during a long outage, and the restart fails with Command failed with error 286 (ChangeStreamHistoryLost). Its saved resume token points at an oplog entry that MongoDB has already deleted. Change streams are the supported way to read MongoDB's oplog for cache invalidation, search indexing, and change data capture (CDC). On MongoDB 8.0 and 9.0, running them in production comes down to where the resume token is stored, how much oplog history stands between the consumer and that error, and how large events get. This guide covers those, plus pre- and post-images, sharded clusters, and the 9.0 change after elections.

TL;DR
  • Save the whole _id of each processed event durably, after the side effect, and make the side effect idempotent. After a restart, delivery is at least once.
  • resumeAfter restarts after a token, startAfter also works after an invalidate event, and startAtOperationTime takes a timestamp. All need that point to still be in the oplog.
  • The oplog window is the hard limit. A consumer that is down for longer fails with ChangeStreamHistoryLost (286), and the only way back is a full re-copy.
  • Use pre- and post-images instead of updateLookup when you need the document as it was at the change; cap them with expireAfterSeconds.
  • Events over 16 MB fail with BSONObjectTooLarge unless $changeStreamSplitLargeEvent is the last pipeline stage (MongoDB 7.0 and 6.0.9 onward).

Scope and review date: November 2026. This post covers change streams on self-managed MongoDB 8.0 and 9.0 replica sets and sharded clusters, with notes for MongoDB Atlas. Options, defaults, error codes, and version numbers were checked against MongoDB's documentation on 9 November 2026. The commands were not run in a lab, so rehearse them on staging.


How does a MongoDB change stream read the oplog?

A change stream is a cursor that returns one event per change, opened on a collection, a database, or a whole deployment. MongoDB builds each event from the oplog and only returns changes written to a majority of data-bearing members, so a rollback cannot take back an event you have processed.

The change streams documentation requires a replica set or sharded cluster, WiredTiger, and replica set protocol version 1. Open the stream on any data-bearing member of a replica set, or through mongos on a sharded cluster. System collections, the admin, local, and config databases, and time series collections cannot be watched.

javascript
// mongosh: one collection
const c1 = db.getSiblingDB("shop").orders.watch()

// One database: every non-system collection in "shop"
const c2 = db.getSiblingDB("shop").watch()

// The whole deployment: every database except admin, local, and config
const c3 = db.getMongo().watch()

Interpretation: all three return the same event format. A wider scope means fewer cursors, but more events to filter with $match.

From a write to a saved resume token

Follow the numbers; the dashed path is how the consumer restarts

Change stream data flow with a durable resume token Step 1: application writes are recorded in the oplog. Step 2: the change stream turns oplog entries into events whose _id is a resume token. Step 3: the consumer receives each event. Step 4: it applies the change to the target with an idempotent write. Step 5: it then saves the event's _id in a token store. Dashed path: on restart, the consumer reopens the stream with resumeAfter set to the saved token. Application writes to primary Oplog capped, time-bound Change stream event _id = token Consumer processes in order Target system idempotent upserts Token store saved after apply 1 2 3 4 5 on restart: watch() with resumeAfter = saved token apply the change then save _id

Order matters: save the token only after step 4 succeeds. A crash between steps 4 and 5 replays one event, which an idempotent write absorbs; the reverse order can lose it.

With access control enabled, the consumer needs the find and changeStream actions on what it watches.

javascript
db.getSiblingDB("admin").createRole( {
   role: "ordersChangeStream",
   privileges: [
      { resource: { db: "shop", collection: "orders" },
        actions: [ "find", "changeStream" ] }
   ],
   roles: []
} )

Interpretation: for a database-level stream, set collection: ""; for a deployment-level stream, set both fields to "".

Which should you use: resumeAfter, startAfter, or startAtOperationTime?

Every change event carries its resume token in the _id field. The documentation describes two kinds: an event token, attached to a real change, and a highwatermark token, which the server generates periodically to show that cluster time has moved on with no matching events. Pass the entire _id document back, never a field inside it. The event reference says the fields inside _id depend on the MongoDB version and sometimes the feature compatibility version, so treat the token as opaque.

OptionStarts fromAfter an invalidate eventTypical use
resumeAfterThe event after the tokenRefusedNormal consumer restart
startAfterThe event after the token, as a new streamAllowedRestart when the last saved event may be an invalidate
startAtOperationTimeA cluster timestampNot applicableStart from a known time, for example a backup point

The three options are mutually exclusive, and each one fails if its starting point is no longer in the oplog. The db.collection.watch() reference adds one more rule in its resume section: reopen with the same pipeline and options that produced the token. A different pipeline can stop the stream from resuming or affect data consistency.

You do not have to wait for an event to get a token. The aggregate and getMore responses carry cursor.postBatchResumeToken, even when the batch is empty, and drivers expose it: PyMongo's ChangeStream.resume_token returns the token for the most recent change or batch. For debugging, the mongosh resumetoken snippet adds decodeResumeToken().

A change event and its resume token

Output from the MongoDB 9.0 manual's $changeStream example; the token is shortened

mongosh: test database
test> var cur = db.names.aggregate( [ { $changeStream: {} } ] )test> cur.next(){ "_id": { _data: "8262E2EE54000000022B022C0100296E5A10...0004" }, "operationType": "insert", "clusterTime": Timestamp({ t: 1659039316, i: 2 }), "wallTime": ISODate("2022-07-28T20:15:16.148Z"), "fullDocument": { "_id": ObjectId("62e2ee54c8756c0d5cf6f072"), "name": "Walker Percy" }, "ns": { "db": "test", "coll": "names" }, "documentKey": { _id: ObjectId("62e2ee54c8756c0d5cf6f072") }}

Store the whole _id: the bold line is the resume token. The event is quoted from the $changeStream reference; clusterTime is not a unique key and is no substitute for the token.

How do you store resume tokens so a consumer can restart safely?

Drivers make one automatic resume attempt after certain errors, using the token in memory. A restart or crash loses it, so the consumer saves its own: apply the change, save the token, and make the apply safe to repeat. A crash between the two replays one event, so delivery after a restart is at least once.

Do not deduplicate on clusterTime. Since MongoDB 8.0, a bulk insert can share one oplog entry and one clusterTime, and events with the same clusterTime can come from different transactions. Key target writes on documentKey.

python
# PyMongo 4.x: collection-level consumer with a durable checkpoint
from datetime import datetime, timezone
from pymongo import MongoClient

client = MongoClient(SOURCE_URI)
orders = client.shop.orders
target = MongoClient(TARGET_URI).reporting.orders
ckpt = client.ops.cs_checkpoints

PIPELINE = [{"$match": {"operationType": {"$in": ["insert", "update", "replace", "delete"]}}}]

def key_filter(event):
    return {k: {"$eq": v} for k, v in event["documentKey"].items()}

def apply(event):
    op = event["operationType"]
    if op == "delete":
        target.delete_one(key_filter(event))
    elif event.get("fullDocument") is not None:
        target.replace_one(key_filter(event), event["fullDocument"], upsert=True)

saved = ckpt.find_one({"_id": "orders-sync"})
resume = {"resume_after": saved["token"]} if saved else {}

with orders.watch(PIPELINE, full_document="updateLookup", **resume) as stream:
    while stream.alive:
        event = stream.try_next()
        if event is not None:
            apply(event)
        token = stream.resume_token        # event token or post-batch token
        if token is not None:
            ckpt.update_one({"_id": "orders-sync"},
                            {"$set": {"token": token,
                                      "savedAt": datetime.now(timezone.utc)}},
                            upsert=True)

Interpretation: the token is saved after every apply and on empty polls, so a quiet collection's checkpoint keeps moving. Replays rewrite the same document and deletes replay as no-ops, so the target converges. On a busy stream, save every few seconds instead; the cost is a longer replay after a crash.

When the target is in the same MongoDB deployment, you can write the change and the token in one transaction, so a restart resumes exactly after the last applied event.

Quiet collections need a moving checkpoint too

A checkpoint saved only on events falls out of the oplog when the watched collection is idle and the rest of the cluster is busy. The Kafka connector and Debezium solve this with heartbeats.

What happens when a consumer falls behind the oplog window?

The oplog is a capped collection. On WiredTiger, its default size is 5% of free disk space, at least 990 MB and at most 50 GB, and by default MongoDB sets no minimum retention time (replica set oplog). The oplog window is the time between its oldest and newest entries. Once the entry behind a saved token is truncated, the stream cannot resume, and the error is not retryable. The MongoDB Kafka connector's troubleshooting page quotes it as:

text
Command failed with error 286 (ChangeStreamHistoryLost): 'PlanExecutor error
during aggregation :: caused by :: Resume of change stream was not possible,
as the resume point may no longer be in the oplog...

Interpretation: 286 is a non-resumable error. Retrying the same token fails the same way, so alert and stop instead of looping. Measure the window on every primary, including every shard's.

javascript
// mongosh, on each primary
rs.printReplicationInfo()

Interpretation: log length start to end is the current window. It shrinks during write bursts such as bulk loads, so read it at peak write volume. A mongos opens a change stream on each shard, so plan for the shortest window among them.

javascript
// How far is the saved checkpoint from the oldest oplog entry?
const oldest = new Date(db.getReplicationInfo().tFirst)
const saved = db.getSiblingDB("ops").cs_checkpoints.findOne({ _id: "orders-sync" })
const headroomHours = (saved.savedAt - oldest) / 3600000
headroomHours

Interpretation: savedAt approximates the token's position when the checkpoint moves on empty polls. A negative result means the token is gone. Alert well before zero.

If the window is shorter than the longest outage a consumer must survive, enlarge the oplog or set a minimum retention period on every data-bearing member. Our oplog sizing guide covers how to size it.

javascript
// Keep at least 48 hours of oplog on this member (example value)
db.adminCommand( { replSetResizeOplog: 1, minRetentionHours: 48 } )

Interpretation: the oplog then grows past its configured size to keep those hours. The replSetResizeOplog reference warns this can exhaust disk space under heavy writes. On Atlas, use the cluster's minimum oplog window setting.

Arbiters stall the stream

In a primary-secondary-arbiter replica set, losing the secondary stops majority commits, so the stream stays open but returns nothing until the secondary returns.

How do you recover a consumer after downtime?

Most restarts are a plain resume. The runbook also covers the case where the token is gone.

Recovering a change stream consumer after downtime

Seven steps; steps 5 to 7 apply only when the saved token has left the oplog

  1. Read the saved checkpoint

    Note the token and when it was saved.

    db.getSiblingDB("ops").cs_checkpoints .findOne( { _id: "orders-sync" } )
  2. Compare it with the oplog window

    Check the oldest entry on every primary; the shortest window decides.

    new Date(db.getReplicationInfo().tFirst)
  3. Reopen with the same pipeline and options

    PIPELINE and OPTS are the consumer's own. Use startAfter if the last saved event was an invalidate.

    db.getSiblingDB("shop").orders.watch(PIPELINE, { ...OPTS, resumeAfter: saved.token })
  4. Watch the first response for error 286

    ChangeStreamHistoryLost means the token is gone. Never fall back to "start from now" silently.

  5. Take a fresh starting token first

    Open a new stream with the same pipeline and save its post-batch token before copying anything.

    db.getSiblingDB("shop").runCommand( { aggregate: "orders", cursor: {}, pipeline: [ { $changeStream: OPTS }, ...PIPELINE ] } ).cursor.postBatchResumeToken
  6. Rebuild the target with a full copy

    Copy every document with the consumer's idempotent upserts.

  7. Resume from the step 5 token

    Idempotent writes absorb the overlap with the copy. Confirm the checkpoint moves.

    db.getSiblingDB("shop").orders.watch(PIPELINE, { ...OPTS, resumeAfter: stepFiveToken })

Main risk: the full copy in step 6 must finish inside the oplog window, or the step 5 token is lost too. Time the copy on staging and enlarge the oplog first if it is close.

If you use the Kafka connector or Debezium

Both read change streams and keep the resume position as Kafka Connect offsets, so the same oplog limit applies. The MongoDB Kafka connector (v3.1 docs) recovers from a lost token by moving to a new offset.partition.name. The Debezium MongoDB connector (3.7 docs) re-snapshots with snapshot.mode=when_needed, and its heartbeat.interval.ms defaults to 0 (off).

ini
# MongoDB Kafka source connector (interval is an example value)
heartbeat.interval.ms=10000

# Debezium MongoDB connector
heartbeat.interval.ms=10000
snapshot.mode=when_needed
capture.mode=change_streams_update_full_with_pre_image

Interpretation: Debezium's default capture.mode is change_streams_update_full. The _with_pre_image modes need changeStreamPreAndPostImages on each captured collection. Our Debezium CDC guide covers the connector setup.

Should you use updateLookup or pre- and post-images?

By default, an update event carries only the delta in updateDescription. fullDocument: "updateLookup" reads the document again when the event is produced. Pre- and post-images, available since MongoDB 6.0, keep the document's state at the time of the change.

updateLookup and pre- and post-images side by side

Switch modes to see what an update event carries and what it costs

fullDocument: updateLookup

QuestionupdateLookup
What fullDocument holdsThe current majority-committed version at lookup time, which may include later changes
Document before the changeNot available
Server setupNone; set it on the watch() call
Extra storageNone
Document deleted before the lookupfullDocument is null; with a $match on it, the stream can fail with "Resume Token Not Found"
Main costA read of the document for each update event

Pre- and post-images

QuestionPre- and post-images
What fullDocument holdsThe document as it was immediately after this change (whenAvailable or required)
Document before the changefullDocumentBeforeChange: whenAvailable or required
Server setupchangeStreamPreAndPostImages enabled per collection
Extra storagePre-images in config.system.preimages, removed by expireAfterSeconds or with the oplog entry
Document deleted before the lookupThe image is still returned while it is retained; required raises an error if it is gone
Main costStorage space and processing time on every write to the collection

Rule of thumb: updateLookup is fine when "latest state" is what the target needs. Use images when the consumer must see each version, or needs the document before an update or delete.

On sharded collections, updateLookup can also omit fullDocument when the update changed a shard key field. Images need one command per collection and one cluster parameter for retention.

javascript
// Enable images on one collection
db.runCommand( { collMod: "orders", changeStreamPreAndPostImages: { enabled: true } } )

// Keep pre-images for at most one day (example value; not supported on Atlas)
db.adminCommand( { setClusterParameter: {
   changeStreamOptions: { preAndPostImages: { expireAfterSeconds: 86400 } }
} } )

// Consume them
db.orders.watch( [], { fullDocument: "whenAvailable",
                       fullDocumentBeforeChange: "whenAvailable" } )

Interpretation: if expireAfterSeconds is off, images live as long as their oplog entries. Set retention longer than the consumer's longest expected outage, or after a resume whenAvailable returns events without images and required fails. Watch the size with db.getSiblingDB("config").system.preimages.totalSize() on each member.

Two operational catches

setClusterParameter is not supported on Atlas clusters. On self-managed clusters, you must disable changeStreamPreAndPostImages on every collection with collMod before a downgrade to an earlier version.

How do you handle change events larger than 16 MB?

A change event is a BSON document with the same 16 MB limit, and an event over it closes the cursor with BSONObjectTooLarge. The documentation suggests documents under 8 MB if you request both images. Shrink events first with a $project stage and by requesting images only when needed. Otherwise, $changeStreamSplitLargeEvent (new in MongoDB 7.0 and 6.0.9) splits oversized events into fragments. It must be the last stage, only one is allowed, and it splits on top-level fields only: a single 20 MB field still fails.

javascript
const cur = db.orders.watch(
   [ { $changeStreamSplitLargeEvent: {} } ],
   { fullDocument: "required", fullDocumentBeforeChange: "required" }
)

let parts = []
while ( !cur.isClosed() ) {
   if ( !cur.hasNext() ) continue
   const ev = cur.next()
   if ( !ev.splitEvent ) { handle(ev); saveToken(ev._id); continue }
   parts.push(ev)
   if ( ev.splitEvent.fragment === ev.splitEvent.of ) {
      handle( Object.assign( {}, ...parts ) )   // merge top-level fields
      saveToken( ev._id )                       // token of the last fragment
      parts = []
   }
}

Interpretation: resuming from a fragment's token starts at the next fragment, so a saved middle token restarts halfway through an event. Save the token only after the final fragment. The stage is resumable only through watch(), not aggregate().

What changes on sharded clusters, and how much load do change streams add?

On a sharded cluster, mongos opens a change stream on every shard, whatever the shard key range you care about, then merges, sorts, and filters the results. It also runs the updateLookup reads. The production recommendations say the merge uses a global logical clock, so events can be processed in the order received.

For each event, mongos checks that every shard has moved past it, so a cold shard adds latency. More frequent no-op writes help: periodicNoopIntervalSecs defaults to 10 seconds, is set at startup, and on Atlas needs Atlas Support.

yaml
# mongod.conf on each shard member; takes effect at restart
setParameter:
  periodicNoopIntervalSecs: 1

Interpretation: 1 second is the documentation's example. If a busy sharded collection outruns mongos, filter with $match. Removing a shard can close a stream that may not be fully resumable; since MongoDB 8.0, movePrimary no longer invalidates streams.

On load: the oplog has no indexes, so every stream scans it, and many narrow streams repeat that scan. Each open stream also holds a connection in getMore, so keep maxPoolSize above the stream count. MongoDB 8.2.0 had a change stream regression of up to 15% against 8.0, fixed in 8.2.1.

javascript
// MongoDB 9.0: change stream counters on this mongod or mongos
const cs = db.serverStatus().metrics.changeStreams
cs.cursor.open.total                          // open change stream cursors now
cs.cursor.totalOpened                         // cumulative; includes resumes
cs.error.nonRetriable.changeStreamHistoryLost // failed resumes: token gone
cs.error.retriable.interruptedDueToReplStateChange

Interpretation: these counters are new in 9.0 (serverStatus reference). A fast-rising totalOpened means frequent reconnects; any changeStreamHistoryLost increase means a re-copy.

Which errors and events end a change stream?

Some interruptions resume on their own; others end the stream. Codes are from MongoDB's error codes reference and the server's error code list.

Event or errorCodeResumable?What to do
invalidate eventEvent, not an errorNot with resumeAfterReopen with startAfter and the invalidate event's _id
InterruptedDueToReplStateChange11602YesDrivers and mongos resume; manual getMore loops resume with resumeAfter
ChangeStreamHistoryLost286NoRun the re-copy runbook
ChangeStreamFatalError280NoFix the pipeline, for example a stage that removes _id
BSONObjectTooLarge10334NoShrink the event or split it

A collection stream receives invalidate after a drop, rename, or dropDatabase that affects the collection. A database stream receives it after dropDatabase. The event closes the cursor.

javascript
// After an invalidate event closed the stream
const next = db.orders.watch( PIPELINE, { startAfter: invalidateEvent._id } )

Interpretation: the new stream reports later changes, for example to a recreated collection with the same name. Decide whether the consumer should follow it or alert instead.

MongoDB 9.0 adds one routine interruption. A stream opened with a readPreference of primary or secondary now returns InterruptedDueToReplStateChange from getMore after an election that changes the serving node's role. Before 9.0, the cursor kept reading from the same node. The 9.0 compatibility notes say primaryPreferred, secondaryPreferred, and nearest are unaffected.

Frequently asked questions

What is a resume token in MongoDB change streams?

A resume token is the _id document of a change event, or the post-batch token a cursor returns when there are no events. Passing it to resumeAfter or startAfter reopens the stream right after that point. Store the whole _id, treat it as opaque, and reopen with the same pipeline and options that produced it.

How long can a change stream consumer be offline?

As long as the oplog still holds the entry behind its saved token. That is the oplog window, which rs.printReplicationInfo() reports as "log length start to end". On a sharded cluster, the shortest shard window counts. Past that point, the resume fails with ChangeStreamHistoryLost (error 286), and the target has to be rebuilt with a full copy.

What is the difference between resumeAfter and startAfter?

Both take a resume token and return the events after it. resumeAfter refuses to continue past an invalidate event, such as a collection drop or rename. startAfter accepts that token and opens a new change stream. startAtOperationTime is the third option and takes a timestamp instead of a token. The three are mutually exclusive.

Do change streams slow down MongoDB?

Each stream scans the oplog, which has no indexes, and holds a connection while it waits for events. A few broad streams cost little. Hundreds of narrow streams, updateLookup on busy collections, and pre-images on write-heavy collections all add load. Measure with the MongoDB 9.0 metrics.changeStreams counters in serverStatus.

Key takeaways

  • Save each event's _id after the change is applied, make the apply idempotent, and move the checkpoint on empty polls too.
  • Measure the oplog window at peak writes on every primary and shard, and alert on checkpoint headroom early.
  • Treat ChangeStreamHistoryLost (286) as a stop-and-rebuild event: take a fresh token, re-copy, then resume from that token.
  • Prefer pre- and post-images when the consumer needs exact versions, and set expireAfterSeconds longer than your longest expected outage.
  • On MongoDB 9.0, expect InterruptedDueToReplStateChange after elections, and watch the new metrics.changeStreams counters.

Run MongoDB change streams in production with JusDB

JusDB's MongoDB consulting team can review your change stream consumers, check oplog windows and pre-image retention against your recovery needs, and write the restart and re-copy runbook for your replica sets or sharded clusters.

Bring your topology and versions, the consumers and connectors that read change streams, and how long each one may be down. Your team approves every production change.

Share this article

Database engineering notes

Articles like this one, in your inbox. No spam, unsubscribe anytime.

JusDB Team

Official JusDB content team

Keep reading

Moving a Multi-Database Valkey Instance into a 9.x Cluster

Move a Valkey instance that uses SELECT 1 to 15 into a 9.x cluster: keep or remap databases, copy each one, verify counts per database, and keep a way back.

Redis18 minNov 10, 2026
Read

Valkey Keyspace Notifications in Production: Expired Events, hexpired and Cluster Mode

Run Valkey keyspace notifications in production: why expired and hexpired events lag the TTL, how to subscribe on every cluster node, and when a stream is safer.

Redis20 minNov 10, 2026
Read

Valkey GLIDE in Production: AZ-Affinity Reads and Migrating from Legacy Clients

Configure Valkey GLIDE AZ-affinity reads on servers and clients, learn what GLIDE 2.5.3 does not support, and migrate from redis-py, Jedis, ioredis, or go-redis one service at a time.

Redis20 minNov 10, 2026
Read