Change streams, TTL indexes and triggers
React to writes as they happen with resumable change streams, let the server expire old data with TTL indexes, and build handlers that survive a replay.
Watching a collection
let token = loadResumeToken();
const pipeline = [
{ $match: {
operationType: { $in: ["insert", "update"] },
"fullDocument.status": "paid"
} }
];
const stream = db.orders.watch(pipeline, {
fullDocument: "updateLookup",
resumeAfter: token
});
for await (const change of stream) {
token = change._id;
await persistResumeToken(token);
await handle(change.fullDocument); // must be idempotent
}- Change streams require a replica set or a sharded cluster. They read the oplog through a supported, resumable interface rather than by tailing it yourself.
- Every event carries a resume token. Persist it, so a restart resumes from the exact position instead of replaying from the beginning or missing events.
- The pipeline filters and projects server-side, so only matching events cross the network. A broad watch on a busy collection is a bandwidth decision as much as a filtering one.
- By default an update event carries only the changed fields.
fullDocument: "updateLookup"fetches the current document, at the cost of a read per event, and the document may already have moved on.
The oplog has a finite window. A consumer that falls further behind than the window can no longer resume and must start again with a full resync, so alert on consumer lag rather than only on consumer errors.
TTL indexes
// expire an hour after createdAt
db.sessions.createIndex({ createdAt: 1 }, { expireAfterSeconds: 3600 });
// expire at the moment stored in the field itself
db.logs.createIndex({ at: 1 }, { expireAfterSeconds: 0 });
// change the window without rebuilding the collection
db.runCommand({
collMod: "sessions",
index: { keyPattern: { createdAt: 1 }, expireAfterSeconds: 7200 }
});
db.sessions.getIndexes()- The background expiry task runs about once a minute, so a document can outlive its expiry by a minute. Never treat TTL as a correctness boundary for access control or billing.
- The field must be a
Dateor an array of dates. A date stored as a string is silently never expired, which is a bug you discover as unbounded growth. - A TTL index is a single-field index, and it does not fire any distinct delete event beyond an ordinary delete in the change stream.
- Delete frees space inside WiredTiger pages but does not shrink the file on disk. Reusable space is the meaningful metric, not file size.
Triggers and event-driven patterns
| Trigger type | Fires when |
|---|---|
| Database trigger | A document is inserted, updated or deleted in a watched collection |
| Scheduled trigger | A cron expression elapses, for example a nightly rollup |
| Authentication trigger | A user is created or deleted, for provisioning and cleanup |
exports = async function (changeEvent) {
const order = changeEvent.fullDocument;
await context.services.get("cluster0")
.db("shop")
.collection("order_events")
.updateOne(
{ eventKey: changeEvent._id.toString() },
{ $setOnInsert: {
orderId: order._id,
type: changeEvent.operationType,
at: new Date()
} },
{ upsert: true }
);
};💡
Change streams provide at-least-once delivery, not exactly-once. A retry after a transient failure can deliver the same event twice, so every handler must be idempotent — upsert on a deterministic key such as the resume token rather than appending unconditionally.
FAQ
Can change streams replace triggers?
In Atlas, triggers are built on change streams and add scheduling, authentication events and retry handling. Outside Atlas you run your own consumer with a persisted resume token: the semantics are the same, and so is the responsibility for availability.
Do TTL deletes reclaim disk space immediately?
No. Deletes free space inside WiredTiger's internal pages, but the file on disk does not shrink unless you compact it. Later writes reuse the space, so plan capacity on reusable space rather than on file size.
Related
Replica sets, failover and read preferences Documents, collections and queries
Last refreshed 2026-09-18.