MongoDB Course — 12 · Change Streams — Real-time CDC, Resume Tokens, Patterns
A change stream is a real-time feed of every document change in a collection, database, or whole cluster. It's MongoDB's built-in change data capture (CDC) — no Kafka Connect, no debezium, no polling. Subscribe, receive insert / update / delete / replace events as they happen, with resumable tokens for crash recovery. This post covers opening change streams at every scope, filtering with an aggregation pipeline, resume tokens, full-document options (including the new fullDocumentBeforeChange), and the canonical use cases: cache invalidation, audit logs, materialised-view refresh, cross-store sync.
Series map: 00 Foundations → … → 11 Sharding → 12 Change Streams (this post) → 13 Security → … → 17 Drivers & Case Studies.
Learning Outcome
- Open a change stream on a collection, database, or cluster.
- Filter events with an aggregation pipeline.
- Resume after a crash using a resume token.
- Use
fullDocumentandfullDocumentBeforeChangeoptions. - Apply the standard patterns: cache invalidation, audit log, materialised view, sync.
Chapter 1 — Mechanics
Change streams are implemented over the same oplog that powers replication (Post 10). The server tails the oplog, applies your aggregation filter, transforms each event into a stable shape, and streams them to your client.
Requirements:
- Replica set (or sharded cluster). Standalone
mongoddoesn't have an oplog. - Reads from the primary by default. Can be served from secondaries with read preference (less common).
Per-event latency: typically tens of milliseconds.
Chapter 2 — Open a change stream
Collection scope
1
2
3
4
5
6
const cs = db.collection('orders').watch();
for await (const change of cs) {
console.log(change);
}
Each change document looks like:
1
2
3
4
5
6
7
8
9
10
{
"_id": { "_data": "82..." }, // resume token
"operationType": "insert", // insert | update | replace | delete | drop | rename | invalidate
"clusterTime": Timestamp(...),
"wallTime": ISODate(...),
"ns": { "db": "shop", "coll": "orders" },
"documentKey": { "_id": "o5" },
"fullDocument": { "_id": "o5", "user_id": 1, ... } // present for inserts
}
For updates:
1
2
3
4
5
6
7
8
9
10
{
"operationType": "update",
"documentKey": { "_id": "o3" },
"updateDescription": {
"updatedFields": { "status": "shipped" },
"removedFields": [],
"truncatedArrays": []
}
}
For deletes, only documentKey is present (the full document is gone).
Database scope
1
2
const cs = client.db('shop').watch(); // all collections in shop
Cluster scope
1
2
const cs = client.watch(); // every collection in every database
Useful for cross-DB sync + cluster-wide audit. Requires admin privilege.
Chapter 3 — Filter with a pipeline
The first argument to watch() is a normal aggregation pipeline applied to events:
1
2
3
4
5
6
7
8
const cs = db.collection('orders').watch([
{ $match: {
operationType: { $in: ["insert", "update"] },
"fullDocument.status": "paid"
}},
{ $project: { _id: 1, documentKey: 1, fullDocument: 1, operationType: 1 } }
])
The server applies the filter before sending — only matching events reach you.
For update events the fullDocument is not present by default; see next chapter.
Chapter 4 — fullDocument options
1
2
3
4
db.collection('orders').watch([], {
fullDocument: "updateLookup"
})
fullDocument values:
| Value | Effect |
|---|---|
"default" |
only present on insert, replace |
"updateLookup" |
re-fetches the doc as it exists now after each update (may differ from the update time if more writes happened) |
"whenAvailable" (7.0+, requires changeStreamPreAndPostImages) |
include post-image if recorded |
"required" (7.0+) |
error if post-image unavailable |
Pre-image (the change before)
1
2
3
4
5
6
7
8
9
10
db.runCommand({
collMod: "orders",
changeStreamPreAndPostImages: { enabled: true }
})
db.collection('orders').watch([], {
fullDocument: "required",
fullDocumentBeforeChange:"required"
})
Now every event includes both:
fullDocument— the doc as-of after the change.fullDocumentBeforeChange— the doc as-of before.
Useful for: diff-based audit, before/after replication to another store, "what exactly changed".
Cost: storing pre-images increases write overhead; enable per collection only when needed.
Chapter 5 — Resume tokens
Every change event has _id = a resume token. If your consumer crashes, persist the last processed token, then resume:
1
2
3
4
const cs = db.collection('orders').watch([], {
resumeAfter: lastToken // or startAfter: lastToken
});
resumeAfter— fails if the token is no longer in the oplog.startAfter— like resumeAfter but tolerant ofinvalidateevents (e.g., collection drop).startAtOperationTime— start at a cluster time instead of a token.
Pattern:
1
2
3
4
5
6
7
8
9
10
11
let lastToken = await loadLastToken();
const cs = db.collection('orders').watch([], {
resumeAfter: lastToken,
fullDocument: 'updateLookup'
});
for await (const change of cs) {
await processChange(change);
await saveLastToken(change._id);
}
If the consumer is down longer than the oplog window (Post 10), the token expires → must re-sync from scratch.
Chapter 6 — Use case: cache invalidation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const cs = db.collection('products').watch([], { fullDocument: 'updateLookup' });
for await (const change of cs) {
const id = change.documentKey._id;
switch (change.operationType) {
case 'insert':
case 'update':
case 'replace':
await redis.set(`product:${id}`, JSON.stringify(change.fullDocument));
break;
case 'delete':
await redis.del(`product:${id}`);
break;
}
}
App reads from Redis; cache stays warm + accurate automatically. Better than a TTL-based cache when staleness matters.
Chapter 7 — Use case: audit log
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const cs = db.collection('orders').watch([], {
fullDocument: 'required',
fullDocumentBeforeChange:'required'
});
for await (const change of cs) {
await db.collection('audit_orders').insertOne({
at: change.wallTime,
op: change.operationType,
document_id: change.documentKey._id,
before: change.fullDocumentBeforeChange,
after: change.fullDocument,
by: change.lsid // session id, if available
});
}
The audit collection becomes a tamper-evident change history (especially if combined with TTL for retention + S3 Object Lock for archived snapshots).
Chapter 8 — Use case: materialised view refresh
Pre-compute daily_revenue with $merge (Post 06), then keep it fresh via change streams:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
const cs = db.collection('orders').watch([
{ $match: { 'fullDocument.status': 'paid' } }
], { fullDocument: 'updateLookup' });
for await (const change of cs) {
if (!change.fullDocument) continue;
const day = trunc(change.fullDocument.at, 'day');
await db.collection('daily_revenue').updateOne(
{ _id: day },
{ $inc: { revenue: change.fullDocument.total, orders: 1 } },
{ upsert: true }
);
}
Reactive instead of batch — daily_revenue updates within seconds of the order.
For exactly-once semantics, watch out: if your consumer crashes after applying but before persisting the resume token, you'll double-count on restart. Mitigations:
- Idempotent updates (e.g., use the order
_idto dedupe). - Transactional consumer (persist token + apply update in one transaction).
- Outbox table on the source side (the order is the source of truth; the view is rebuildable).
Chapter 9 — Use case: cross-store sync
Stream MongoDB changes into Elasticsearch / OpenSearch / Algolia for full-text search; into Redis for caching; into BigQuery / Snowflake for analytics; into another MongoDB cluster for cross-region replication.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const cs = db.collection('articles').watch([], { fullDocument: 'updateLookup' });
for await (const change of cs) {
switch (change.operationType) {
case 'insert':
case 'update':
case 'replace':
await openSearch.index({
index: 'articles',
id: change.documentKey._id,
body: change.fullDocument
});
break;
case 'delete':
await openSearch.delete({ index: 'articles', id: change.documentKey._id });
break;
}
}
Atlas does this natively via Atlas Triggers (Post 16) — a serverless function fires on every change event. No infra to run.
Chapter 10 — Operational concerns
Long-running consumers
A change stream client holds a cursor on the server. Treat it like a long-running connection:
- Always wrap in error handling — network blips throw; reconnect with the last token.
- On clean shutdown, persist the last token + close the cursor.
- Use one consumer per collection per business purpose. Don't share streams across handlers.
Performance impact on the cluster
Change streams read from the oplog — extra reads, but typically cheap. Many streams = many oplog readers = some CPU + memory cost on the primary. Atlas handles thousands of streams comfortably.
Pre-image cost
Recording pre-images doubles per-update write work + storage. Enable only where the audit / before-image is needed.
Sharded clusters
Change streams on sharded clusters merge events from every shard, ordered by cluster time. Latency a touch higher than on a single replica set.
Chapter 11 — Limits
| Limit | Default |
|---|---|
| Single event size | 16 MB (BSON) |
| Resume window | depends on oplog window (Post 10) |
| Pipeline operators | subset of full aggregation; basic $match, $project, $addFields, $replaceRoot, $replaceWith, $redact |
| Pre/post images | per-collection opt-in; cost on writes |
Large updates may produce events too big — adjust your pipeline to project only what consumers need.
Chapter 12 — Atlas Triggers (preview)
For serverless use, Atlas runs your code on every change event:
1
2
3
4
5
6
// Atlas Trigger function — runs on each change
exports = async function(changeEvent) {
const fullDoc = changeEvent.fullDocument;
// ... your logic
};
No infrastructure; pay per invocation. Post 16 covers Triggers + Atlas Functions deeply.
For local / self-hosted, the long-running consumer pattern (Chapter 5) is the equivalent.
Chapter 13 — Common pitfalls
| Pitfall | Cause | Fix |
|---|---|---|
| Resume fails with "token expired" | consumer down > oplog window | enlarge oplog; or accept full re-sync from startAtOperationTime |
Missing fullDocument on updates |
default mode | use fullDocument: 'updateLookup' (or required pre-image) |
| Pre-image undefined | collection not enabled | collMod with changeStreamPreAndPostImages |
| Double-processed events on crash | persisted token after apply but apply non-idempotent | idempotent updates OR transactional consumer |
| Big event (> 16 MB) | full-document mode + huge doc | project subset before sending |
| Stream slow on busy collection | lots of unrelated events; client filters them | move filter to the pipeline ($match) — server side |
invalidate event |
collection dropped or renamed | use startAfter instead of resumeAfter |
| One consumer for everything | bottleneck | one consumer per use case |
Practice Exercises
Exercise 1 — Print every change
Open a change stream on shop.orders. In another window insert + update + delete docs. Watch events print.
Exercise 2 — Resume token
Persist the last token to a JSON file after each event. Kill the consumer. Insert a few docs. Restart; confirm catch-up.
Exercise 3 — Cache invalidator
Run a Node consumer that pushes product changes into a Map (in-memory cache). Read from the cache in another script. Update a product; confirm the cache reflects the change in < 1 s.
Exercise 4 — Audit log
Enable pre-images on orders. Build the audit collection from Chapter 7. Update an order; inspect the audit doc.
Exercise 5 — Materialised view
Pipe paid-order events into daily_revenue. Insert a few paid orders for the same day; confirm daily_revenue totals match.
Exercise 6 — Stream-to-Elasticsearch (simulation)
Print to stdout in OpenSearch-bulk format every article change. Validate end-to-end pattern.
Cheat-sheet
| What | How |
|---|---|
| Open collection | db.coll.watch([pipeline], options) |
| Open db | client.db('x').watch() |
| Open cluster | client.watch() |
| Iterate | for await (const e of cs) { ... } |
| Full doc on updates | { fullDocument: 'updateLookup' } |
| Pre-image | enable on collection + fullDocumentBeforeChange: 'required' |
| Resume after crash | persist change._id; pass resumeAfter (or startAfter) |
| From cluster time | { startAtOperationTime: ts } |
| Filter events | [{ $match: { ... } }] first stage |
| Single event size | 16 MB cap |
| One consumer | per business purpose; not shared |
| Atlas | Triggers for serverless event handling |
| Sharded | events ordered by cluster time across shards |
What's next
Post 13 covers security — authentication (SCRAM, x.509, LDAP, IAM), authorization (RBAC + custom roles), TLS, encryption at rest, Client-Side Field-Level Encryption (CSFLE), and Queryable Encryption (GA in 7.0).
References
Note: this post is in progress. Planned additions: outbox-pattern walkthrough; multi-region cross-cluster sync; bench numbers on pre-image overhead.










