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() })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)
})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")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() }
})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
expiresAtas 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 withbsonType: "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
_idas 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 }
})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
expireAfterSecondstoo, 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.