A TTL index is an ordinary single-field index on a date plus expireAfterSeconds. A background thread wakes every 60 seconds, scans each TTL index for keys past the cutoff and deletes those documents. Index Properties covers it as an index option; this is what it does to the data.
db.sessions.createIndex({ lastSeen: 1 }, { expireAfterSeconds: 1800 });
db.sessions.createIndex({ expireAt: 1 }, { expireAfterSeconds: 0 });
const now = new Date();
db.sessions.insertMany([
{ _id: 'old', lastSeen: new Date(now - 7200000) }, { _id: 'fresh', lastSeen: now },
{ _id: 'dated', expireAt: new Date(now - 5000) },
{ _id: 'future', expireAt: new Date(now.getTime() + 3600000) }]);
const before = db.serverStatus().metrics.ttl.deletedDocuments, t0 = Date.now();
while (db.sessions.countDocuments() > 2) sleep(2000);
print('after ' + Math.round((Date.now() - t0) / 1000) + 's: ' +
db.sessions.find({}, { _id: 1 }).toArray().map(d => d._id).join(' ') +
', ttl deleted ' + (db.serverStatus().metrics.ttl.deletedDocuments - before));after 60s: fresh future, ttl deleted 2
expireAfterSeconds: 1800 on lastSeen is a sliding window: touch the field on each request and the session lives on. expireAfterSeconds: 0 on expireAt is the flexible form — the document dies at the instant it carries, so one index holds both a 10-minute password reset and a 30-day invitation. Repeated runs waited 6 to 60 seconds: that 60 is the sleep interval, not a deadline, and a single-threaded deleter falls behind on a busy server, so filter expired documents out in the query ({ expireAt: { $gt: new Date() } }) when correctness depends on it. A non-date value is skipped, and on a time series collection expireAfterSeconds goes on the collection, dropping whole buckets.