MongoDB Mastery
MongoDB Mastery lessons / Module 20: Change Streams

Change streams and resume tokens

Level 3: Advanced22 min

Subscribe to inserts, updates, replaces and deletes in real time, filter them with a pipeline, and resume reliably after failures.

This lesson is interactive. Run every example against an in-browser MongoDB, see explain() plans drawn as trees, and take the quiz.
Open the interactive lesson
You will learn to
  • Open change streams on a collection, database or deployment
  • Filter events with $match and choose fullDocument options
  • Resume with resume tokens and understand the oplog window
  • Use pre- and post-images (6.0+)

conceptThe concept

Open the interactive lesson to use this diagram.

db.orders.watch(pipeline, options) returns a cursor of change events: { _id: <resume token>, operationType, ns, documentKey, fullDocument?, updateDescription?, clusterTime }.

  • Filter with a pipeline: [{ $match: { operationType: "update", "updateDescription.updatedFields.status": { $exists: true } } }].
  • fullDocument: "updateLookup" fetches the current document for updates (it may be newer than the change). fullDocument: "whenAvailable"/"required" use stored post-images (6.0+, when changeStreamPreAndPostImages is enabled on the collection), and fullDocumentBeforeChange gives the pre-image.
  • Resume: store the _id of the last processed event and reopen with resumeAfter or startAfter (the latter also works after an invalidate). startAtOperationTime starts from a cluster time.

whyWhy it matters

Change streams replace polling and fragile oplog tailing for event-driven architectures: cache invalidation, search index sync, notifications, audit trails, CDC into Kafka or a warehouse. They work across failovers and in sharded clusters with a global order.

Use caseReal-world scenario

When an order becomes shipped, a notification service sends an email. It watches updates where updatedFields.status is "shipped", processes each event, and stores the resume token in its own collection after each success. After a deploy or crash it resumes exactly where it stopped, as long as the oplog still covers that point.

Try itExamples

Run and edit these in the interactive lesson.

Watch updates to status
db.orders.updateOne({ orderNo: "ORD-100002" }, { $set: { status: "paid" } })
const cs = db.orders.watch([{ $match: { operationType: "update", "updateDescription.updatedFields.status": { $exists: true } } }], { fullDocument: "updateLookup" })
db.orders.updateOne({ orderNo: "ORD-100002" }, { $set: { status: "shipped" } })
db.orders.updateOne({ orderNo: "ORD-100002" }, { $inc: { total: 0.5 } })
const ev = cs.tryNext()
ev.operationType
ev.updateDescription
ev.fullDocument.orderNo
cs.tryNext()
The second update does not touch status, so it is filtered out and tryNext() returns null.
Resume after a restart
const s1 = db.orders.watch()
db.orders.insertOne({ orderNo: "ORD-CS-1", status: "pending", createdAt: new Date(), total: 5 })
const first = s1.tryNext()
const token = first._id
s1.close()
db.orders.insertOne({ orderNo: "ORD-CS-2", status: "pending", createdAt: new Date(), total: 6 })
const s2 = db.orders.watch([], { resumeAfter: token })
s2.tryNext().fullDocument.orderNo
Database-wide stream
const all = db.watch([{ $match: { operationType: { $in: ["insert", "delete"] } } }])
db.reviews.insertOne({ productId: "P001", customerId: 1, rating: 5, title: "Great", createdAt: new Date() })
const e = all.tryNext()
e.ns

internalsUnder the hood

Change streams are an aggregation over the oplog, run with majority read concern so events are never rolled back. On sharded clusters, mongos merges per-shard streams in cluster-time order. A resume token encodes the cluster time and document key; if the oplog has rolled past it (the consumer was down longer than the oplog window), resuming fails with ChangeStreamHistoryLost and you need a full resync. Size the oplog for your longest expected consumer outage.

Avoid / preferDo this, not that

Avoid
setInterval(() => db.orders.find({ updatedAt: { $gt: last } }), 1000)
Prefer
const cs = db.orders.watch([
  { $match: { operationType: { $in: ["insert", "update"] } } }
])

Polling misses deletes, depends on every writer setting updatedAt, and costs a query per second. A change stream pushes every change from the oplog in order.

Avoid
cs.on("change", e => handle(e))
Prefer
cs.on("change", async e => { await handle(e); await saveToken(e._id) })
// on restart: collection.watch(pipeline, { resumeAfter: savedToken })

Without a stored resume token a restart either loses changes or reprocesses everything. Save the token after each handled event.

PitfallsCommon mistakes

  • Not persisting resume tokens, so a restart either misses or reprocesses events.
  • Consumers down longer than the oplog window: ChangeStreamHistoryLost.
  • Assuming updateLookup returns the document as it was right after that change; it returns the current version.
  • Heavy processing inside the change stream loop instead of handing work to a queue.
  • Opening one change stream per user or per entity; each is a server-side cursor scanning the oplog. Open few streams and fan out in the application.

LimitsWhen not to use it

A change stream is not a message queue with retention. If a consumer is down longer than the oplog window, its resume token is gone and it must resync from scratch. For fan-out to many consumers or long retention, feed a broker (for example with the Kafka connector) instead of opening many streams.

SecuritySecurity notes

Change events can carry full documents and pre-images. Strip sensitive fields with $project in the stream pipeline before they leave the database, and remember that stored pre-images keep old values around longer than the documents themselves.

ChallengeTry it yourself

Open a change stream on a scratch collection, insert a document and update it, then collect the operation types the stream delivered.

Show one solution
One solution
db.cs_lab.drop()
db.createCollection("cs_lab")
const cs = db.cs_lab.watch()
db.cs_lab.insertOne({ _id: 1, status: "new" })
db.cs_lab.updateOne({ _id: 1 }, { $set: { status: "paid" } })
const seen = []
let ev
while ((ev = cs.tryNext())) seen.push(ev.operationType)
seen

InterviewQuestions you should be able to answer

How do change streams guarantee you do not process rolled-back data?

They only return majority-committed changes, which cannot be rolled back. That is why they need a replica set (or sharded cluster) and why heavy replication lag delays events.

How would you build reliable CDC from MongoDB to Kafka?

Use the MongoDB Kafka source connector or a custom consumer on change streams with a pipeline filter; persist the resume token transactionally with your progress (or in Kafka offsets); make consumers idempotent keyed by documentKey and cluster time; size the oplog for the maximum consumer outage; monitor lag between clusterTime and now; plan a re-snapshot procedure for ChangeStreamHistoryLost.

QuizCheck your understanding

4 questions with instant feedback in the interactive lesson; a wrong answer goes into your review deck.