MongoDB · lesson 6 of 10
Intermediate
6- 2 Embedding vs Referencing: A 5.9MB Document and a 50x Fetch Time
- 3 explain() Proves It: 200,000 Docs Scanned vs. a Covered Query That Touches Zero
- 4 The Aggregation Stage-Order Bug: Wrong Results, No Error
- 5 When the Aggregation Optimizer Can (and Can't) Use Your Index
- 6 Change Streams: Watching a Collection in Real Time (Replica Set Required)
- 7 Multi-Document Transactions: When One Update Isn't Enough
Advanced
3Also part of MongoDB
1Change Streams: Watching a Collection in Real Time (Replica Set Required)
watch() fails outright on a standalone server — change streams need a replica set, even a local one. Once that's running: real insert/update/delete events, filtering by operation type, and resuming after a disconnect.
By SkillByExample · Published
Change streams need something a single standalone server doesn’t have — and once that’s in place, watching a collection change in real time is only a few lines.
The bug: watch() on a standalone server
docker exec mongo-lesson mongosh --quiet --eval '
db = db.getSiblingDB("bookshop");
db.books.watch();
'
MongoServerError: The $changeStream stage is only supported on replica sets or mongos
Every earlier lesson in this track ran against a single standalone container. Change streams specifically refuse to work there — not a timeout, not a slow response, an outright rejection.
The fix: a real replica set
docker network create mongo-rs
docker run -d --name mongo-rs1 --network mongo-rs -p 27018:27017 mongo:8 --replSet rs0 --bind_ip_all
docker run -d --name mongo-rs2 --network mongo-rs -p 27019:27017 mongo:8 --replSet rs0 --bind_ip_all
docker run -d --name mongo-rs3 --network mongo-rs -p 27020:27017 mongo:8 --replSet rs0 --bind_ip_all
docker exec mongo-rs1 mongosh --quiet --eval '
rs.initiate({
_id: "rs0",
members: [
{ _id: 0, host: "mongo-rs1:27017" },
{ _id: 1, host: "mongo-rs2:27017" },
{ _id: 2, host: "mongo-rs3:27017" }
]
});
'
docker exec mongo-rs1 mongosh --quiet --eval 'rs.status().members.map(m => ({name: m.name, state: m.stateStr}))'
[
{ name: 'mongo-rs1:27017', state: 'PRIMARY' },
{ name: 'mongo-rs2:27017', state: 'SECONDARY' },
{ name: 'mongo-rs3:27017', state: 'SECONDARY' }
]
Three containers on their own Docker network, told about each other via rs.initiate(), and
an election settles on one PRIMARY. This same three-node replica set is what the rest of
this track’s remaining lessons use — a single-node replica set would also unlock change
streams, but a real multi-node one is what later lessons on failover actually need.
Watching a collection
docker exec mongo-rs1 mongosh --quiet --eval '
db = db.getSiblingDB("bookshop");
const stream = db.books.watch();
db.books.insertOne({ title: "Dune", author: "Frank Herbert", price: 12.99 });
const change = stream.next();
printjson({ operationType: change.operationType, documentKey: change.documentKey, fullDocument: change.fullDocument });
stream.close();
'
{
operationType: 'insert',
documentKey: { _id: ObjectId('6aa6c6d99514865fec6d73c8') },
fullDocument: {
_id: ObjectId('6aa6c6d99514865fec6d73c8'),
title: 'Dune',
author: 'Frank Herbert',
price: 12.99
}
}
watch() returns a cursor immediately; the insert that follows is what stream.next() blocks
on and returns. No polling, no re-querying the collection to spot what changed — the change
itself arrives as a structured event.
docker exec mongo-rs1 mongosh --quiet --eval '
db = db.getSiblingDB("bookshop");
const stream = db.books.watch();
db.books.updateOne({ title: "Dune" }, { $set: { price: 14.99 } });
const change = stream.next();
printjson({ operationType: change.operationType, updatedFields: change.updateDescription.updatedFields });
stream.close();
'
{ operationType: 'update', updatedFields: { price: 14.99 } }
An update event reports exactly which fields changed (updateDescription.updatedFields), not
the whole document — useful for reacting only to a change that actually matters (a price
change, say) without diffing the full document yourself.
Resuming after a disconnect
docker exec mongo-rs1 mongosh --quiet --eval '
db = db.getSiblingDB("bookshop");
const stream1 = db.books.watch();
db.books.insertOne({ title: "Foundation", price: 9.99 });
const change1 = stream1.next();
const resumeToken = change1._id;
stream1.close();
db.books.insertOne({ title: "Neuromancer", price: 11.5 });
const stream2 = db.books.watch([], { resumeAfter: resumeToken });
const change2 = stream2.next();
printjson({ operationType: change2.operationType, title: change2.fullDocument.title });
stream2.close();
'
{ operationType: 'insert', title: 'Neuromancer' }
The first stream closed before Neuromancer was inserted — nothing was watching when that
write happened. Resuming with the saved token still receives it: the oplog behind the replica
set retained the change, and resumeAfter picks up exactly where the closed stream left off,
not from whenever the new stream happens to start.
Practice
1. Filter a change stream to only one operation type.
docker exec mongo-rs1 mongosh --quiet --eval '
db = db.getSiblingDB("bookshop");
const stream = db.books.watch([{ $match: { operationType: "delete" } }]);
db.books.insertOne({ title: "TempBook", price: 1 });
db.books.deleteOne({ title: "TempBook" });
const change = stream.next();
printjson({ operationType: change.operationType, documentKey: change.documentKey });
stream.close();
'
{ operationType: 'delete', documentKey: { _id: ObjectId('6aa6c6fe9161881927bf1d94') } }
The insert happened first but never reached stream.next() — the $match pipeline filtered
it out before it was ever handed to the consumer, leaving only the delete event.
2. Check the replica set's primary directly, without parsing the full status output.
docker exec mongo-rs1 mongosh --quiet --eval 'rs.status().members.find(m => m.stateStr === "PRIMARY").name'
mongo-rs1:27017
rs.status().members is an array — filtering it in JavaScript inside mongosh is often
faster than reading the full structure by eye, especially once a cluster has more than a
handful of members.
3. Confirm a standalone container (from earlier lessons) still can't watch, for contrast.
docker exec mongo-lesson mongosh --quiet --eval '
try {
db.getSiblingDB("bookshop").books.watch();
} catch (e) {
print("still fails:", e.codeName);
}
'
still fails: Location40573
Same error class as the very first example in this lesson — mongo-lesson was never
converted to a replica set, so it never gained an oplog, so it still can’t support change
streams. The two containers (mongo-lesson and the mongo-rs* set) are genuinely different
deployments, not the same server reconfigured.
Next: multi-document transactions — when they’re actually needed, and when they’re not.