Let MongoDB Delete Your Expired Data: TTL Indexes as an Engineering Decision
MongoDB TTL indexes delete expired sessions, tokens, and logs for you — no cron job required. Here's how they work, where they fall short, and how to verify them.
05 Jul 2026, 07:25 UTC

The cleanup job you don't have to write
Almost every application accumulates data with a natural shelf life: login sessions, one-time passcodes, password-reset tokens, rate-limit counters, event logs. The default engineering answer is a cron job or scheduled task that runs DELETE ... WHERE created_at < now() - interval. That job is one more thing to deploy, monitor, and debug when it silently stops running.
MongoDB offers a built-in alternative: the TTL (time-to-live) index. You declare an expiry policy on the collection, and a background thread inside mongod deletes expired documents for you. No cron, no worker, no cleanup code in your app. The trade-off is that deletion is eventual, not exact — and understanding that trade-off is the real engineering decision.
How a TTL index works
A TTL index is a regular single-field index with one extra option, expireAfterSeconds. The indexed field must contain BSON Date values (or an array of dates, in which case the earliest date is used). Run this in mongosh against your application database — you'll need a role with index-creation privileges, such as dbOwner on that database:
db.sessions.createIndex(
{ lastTouched: 1 },
{ expireAfterSeconds: 1800 } // 30 minutes
)From that point on, any document whose lastTouched is more than 30 minutes in the past becomes eligible for deletion. A background thread called the TTL monitor wakes up roughly every 60 seconds and removes eligible documents in batches.
That "roughly" matters. A document expiring at 14:00:00 might still exist at 14:00:45 because the monitor hasn't swept yet. TTL expiry is best-effort garbage collection, not a real-time guarantee.
Worked example: sliding session expiration
A fixed expiry is fine for OTP codes, but sessions usually want sliding expiration — active users stay logged in, idle users get logged out. The trick is to update the TTL field on activity:
// On each authenticated request (run from your app server):
db.sessions.updateOne(
{ _id: sessionId },
{ $set: { lastTouched: new Date() } }
)Each request pushes the expiry horizon forward by the full TTL window. Idle sessions age out; active ones persist. The application never writes a delete.
For per-document lifetimes — say, a password-reset token valid for exactly 15 minutes while an email-change token lives for 24 hours — use expireAfterSeconds: 0 and store an absolute expiry date instead:
db.tokens.createIndex(
{ expiresAt: 1 },
{ expireAfterSeconds: 0 } // delete when 'expiresAt' is in the past
)
db.tokens.insertOne({
userId: "u123",
kind: "password-reset",
expiresAt: new Date(Date.now() + 15 * 60 * 1000)
})With expireAfterSeconds: 0, the date in the document itself is the expiry moment, giving you per-row control with a single index.
The trade-offs you actually sign up for
Expiry lag means no security boundary. Because the monitor sweeps periodically, an expired token may still be readable for tens of seconds. Always check expiry in application logic too — treat TTL as cleanup, not enforcement. For example, compare expiresAt against the current time when validating a token, even though the TTL index will eventually remove it.
Silent failure on bad data. Documents missing the indexed field, or holding a non-date value (a common bug is storing an ISO string instead of a Date), are never expired. Nothing errors; the collection just grows. If you adopt TTL, add a periodic sanity check on collection size or a count of old documents.
Delete load is real. On a replica set, TTL deletions happen on the primary and replicate to secondaries as ordinary deletes. If a million documents expire in the same minute — say you backfilled data with identical timestamps — the monitor generates a delete spike that shows up as write and oplog load. Mitigate by staggering expiry times (per-document dates naturally do this) or adding small random jitter to the date field at insert time.
Changing the window is cheap. To adjust the lifetime, you don't drop and recreate the index — use collMod:
db.runCommand({
collMod: "sessions",
index: { keyPattern: { lastTouched: 1 }, expireAfterSeconds: 3600 }
})Behavior details — monitor interval, supported index types, collMod support — vary across MongoDB versions, so confirm against the version you run before relying on specifics.
Verify it before you trust it
Three quick checks, all in mongosh:
- Confirm the index and its window:
db.sessions.getIndexes()and look for theexpireAfterSecondsfield on the index definition. - Prove the lag to yourself: create a scratch collection with
expireAfterSeconds: 60, insert a document datednew Date(), and poll it. It will disappear — but probably not at exactly 60 seconds. - Under realistic volume, watch TTL activity via
db.serverStatus().metrics.ttl(deleted documents and passes) to confirm the monitor keeps up with your expiry rate.
The decision in one line
Choose a TTL index when you want cheap, reliable, eventually-consistent cleanup of date-bounded data and you can tolerate seconds of lag. Keep application-side expiry checks for anything security-sensitive, and keep the cron job only if you need hard real-time deletion or complex retention logic. For sessions, OTPs, and transient tokens, letting the database take out its own trash is usually the simpler, more reliable system.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.