Ch. 12 · MongoDB

MongoDB TTL Indexes and Eventual Expiration

How MongoDB's TTL monitor deletes expired documents, why they can linger past their deadline, and how to filter, tune and size expiry safely.

~5 min readadvancedupdated Oct 7, 2026

A TTL index tells MongoDB to delete documents automatically once a date field is old enough. It’s the standard way to expire sessions, password-reset tokens, idempotency keys and verbose logs. It’s also widely misunderstood: people treat it as a precise timer, and it isn’t. The deletion is done by a background task, and “expired” and “gone” are two different moments.

Commands are mongosh for MongoDB 7 and 8. Outputs are illustrative; no live server was available while writing.

Two ways to set it up

Fixed lifetime after a timestamp. Sessions expire 30 minutes after their last activity:

db.sessions.createIndex({ lastSeenAt: 1 }, { expireAfterSeconds: 1800 })
db.sessions.insertOne({ _id: "s_9f2c", userId: 7, lastSeenAt: new Date() })
JavaScript

Each request that touches the session updates lastSeenAt, pushing the expiry forward.

Explicit expiry time per document. Set expireAfterSeconds: 0 and store the exact deletion time:

db.resetTokens.createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 })
db.resetTokens.insertOne({
  _id: "rt_5a1e", userId: 7,
  expiresAt: new Date(Date.now() + 15 * 60 * 1000)
})
JavaScript

This form lets different documents have different lifetimes (a “remember me” session for 30 days, a normal one for 1 day) without separate indexes. It’s usually the more flexible choice.

How deletion actually happens

A single background thread, the TTL monitor, wakes up every 60 seconds by default on the primary. For each TTL index, it deletes documents whose indexed date plus expireAfterSeconds is in the past. Secondaries don’t run their own TTL deletes; they receive the deletes through replication like any other write.

So a document can survive past its expiry by:

  • up to about a minute, waiting for the next pass;
  • longer if the pass is busy: when a lot of documents expire at once, deletion takes time, and on a heavily loaded server it can fall well behind;
  • longer still on secondaries that are lagging.

The documentation is explicit that there’s no guarantee expired data is deleted immediately. That’s why the title says eventual expiration.

The bug this causes

A password-reset handler that does:

const token = await db.collection("resetTokens").findOne({ _id: tokenId })
if (!token) return res.status(400).send("expired")
JavaScript

accepts a token for up to a minute or more after its deadline, because it trusts the TTL monitor to have removed it. For security-sensitive data, always check the expiry in the query:

const token = await db.collection("resetTokens").findOne({
  _id: tokenId,
  expiresAt: { $gt: new Date() }
})
JavaScript

Now the token is invalid exactly on time, and the TTL index’s only job is housekeeping. That framing (correctness in the query, cleanup by TTL) is the answer interviewers want.

Rules and gotchas

  • The field must be a date. A BSON date, or an array of dates (the earliest one is used). If a writer stores expiresAt as a string like "2026-10-07T10:00:00Z" or as a number of milliseconds, that document never expires. No error, just a collection that grows. A schema validator with bsonType: "date" prevents it.
  • Documents without the field never expire. Same silence, same growth.
  • Single-field indexes only. TTL works on a single-field index; a compound index can’t be a TTL index. You can combine TTL with a partialFilterExpression, for example to expire only { status: "anonymous" } carts.
  • Not on capped collections, and you can’t use _id as the TTL field.
  • Time zones don’t matter. BSON dates are UTC milliseconds; the monitor compares against server UTC time. Clock skew on the primary does matter, since it decides what “now” is.

Changing the lifetime

To change a fixed lifetime, don’t drop and recreate the index (on a large collection that’s a long rebuild with no expiry while it runs). Use collMod:

db.runCommand({
  collMod: "sessions",
  index: { keyPattern: { lastSeenAt: 1 }, expireAfterSeconds: 3600 }
})
JavaScript

You can also convert an existing non-TTL single-field index to a TTL index this way. Shortening the lifetime on a big collection means the next passes have a lot to delete, which brings us to load.

Expiry is a write workload

Every expired document is a delete: it touches the collection, every index on the collection, and the oplog, and then gets replicated. Delete 30 million log entries at midnight because they were all written with expiresAt set to “end of day” and you’ve scheduled a burst of 30 million deletes. Effects include replication lag, cache pressure and slower foreground traffic.

Ways to keep it smooth:

  • Spread expiry times. Use “30 days after creation” rather than “midnight on the 30th day”.
  • For very large time-bucketed data, consider one collection per day or month and drop whole collections. drop() is nearly instant compared with millions of deletes.
  • Time-series collections support expireAfterSeconds too, and delete whole buckets, which is much cheaper per measurement.
  • Watch serverStatus().metrics.ttl (deletedDocuments, passes) to see whether the monitor keeps up.

Interview practice

A service stores idempotency keys for payment requests with a TTL of 24 hours, using createIndex({ createdAt: 1 }, { expireAfterSeconds: 86400 }). After a deployment, the collection starts growing without limit, and a week later it’s 40 GB. The index still exists. What do you check, and what’s the likely cause?

Answer

A TTL index that exists but doesn’t delete usually means the documents don’t qualify. I’d check three things in order.

First, the type of createdAt on recent documents: db.idem.aggregate([{ $group: { _id: { $type: "$createdAt" }, n: { $sum: 1 } } }]). A deployment that changed the serializer (for instance, writing an ISO string or epoch milliseconds instead of a native date) would leave new documents with a non-date createdAt, and those never expire. That fits “started after a deployment” well.

Second, whether new documents have the field at all, since missing fields also never expire.

Third, whether the monitor runs and keeps up: serverStatus().metrics.ttl.passes should increase every minute, and deletedDocuments should move. If passes increase but deletes don’t, it’s the data.

The fix would be to correct the writer to store a real date, add a validator requiring createdAt to be a date, and backfill the bad documents with an update pipeline using $toDate. Once converted, the monitor will start deleting the backlog, so I’d convert in batches to avoid a sudden mass delete, and keep an eye on replication lag. I’d also confirm the idempotency check itself filters on createdAt within 24 hours, so it doesn’t depend on TTL timing.

Further reading

More in MongoDB

read ✓MongoDB · mid

MongoDB Capped Collections

Size a capped collection for a recent-errors feed, follow it with a tailable cursor, and see why TTL indexes suit time-based retention better.

~6 min readread →
read ✓MongoDB · easy

MongoDB Compound Indexes and Query Order

How field order in a MongoDB compound index decides which queries it serves, with the equality, sort, range rule worked through step by step.

~6 min readread →
read ✓MongoDB · hard

MongoDB Explain Plans and Examined Documents

How to read MongoDB explain output: the plan stages, keys and documents examined versus returned, and what each pattern tells you to fix.

~6 min readread →
esc