Skip to main content
MongoDB · lesson 6 of 10
MongoDB intermediate Lesson 6 of 10

Change 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

mongodb change-streams replica-set real-time

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.

Frequently Asked Questions

Why do change streams require a replica set, even for local development?
Change streams are built on top of the oplog — the operation log a replica set's members use to replicate writes to each other. A standalone mongod doesn't maintain an oplog at all, so there's nothing for a change stream to read from; enabling replication (even a single-member replica set) is what creates one.
What does resumeAfter actually let me recover from?
A resume token captured from a processed change lets a new watch() pick up exactly after that point, even if the process restarted, the connection dropped, or the previous stream was explicitly closed in between. This lesson closes a stream, performs a write while nothing is watching, then resumes and still receives that write's change event — the gap didn't lose anything, because MongoDB's oplog retained it.
Can a change stream filter to only certain kinds of changes?
Yes — watch() accepts an aggregation pipeline, the same $match syntax used in a regular aggregation, filtering on fields like operationType before events reach the application. This lesson's filtered example only ever receives delete events; an insert that happened in between was silently skipped, not queued up.
Is a change stream the same thing as a database trigger?
Conceptually similar — code reacting to a data change — but a change stream is a cursor your own application code reads from, not server-side logic that runs inside the database. MongoDB Atlas offers actual server-side triggers built on top of change streams for teams that want that instead of running their own consumer process.
What happens if my application crashes while consuming a change stream and doesn't save a resume token?
Changes that occurred while nothing was watching and no token was saved are permanently unrecoverable through the change stream — the oplog itself is a bounded, rolling log, not permanent storage. Production consumers should persist the latest processed resume token somewhere durable (not just in memory) specifically to survive a crash without this gap.