MongoDB TTL indexes — auto-expire in one line

NeoMail expires old messages automatically. There's no cron job, no scheduler, no nightly cleanup script. The entire feature is one MongoDB index. When TTL indexes are the right tool, when they aren't, and the gotchas that catch most teams the first time.

The pattern

A TTL index in MongoDB is a regular index on a date field, with an extra option: expireAfterSeconds. MongoDB's background process scans this index roughly every 60 seconds and deletes documents whose indexed date is more than the configured number of seconds in the past.

For NeoMail, each message has an expiresAt field set at insert time. One index does the rest:

// One-time setup
db.messages.createIndex(
  { expiresAt: 1 },
  { expireAfterSeconds: 0 }
);

// Insert with an absolute expiry date
db.messages.insertOne({
  to:        "user@example.com",
  subject:   "Welcome",
  body:      "...",
  expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) // 30 days
});

That's the entire feature. After 30 days, the message disappears. No code runs to make this happen — MongoDB's TTL monitor handles it.

Two modes (and which one to use)

There are two ways to configure a TTL index. Pick the right one or you'll get surprised:

Mode 1: Absolute expiry date (use this)

db.messages.createIndex(
  { expiresAt: 1 },
  { expireAfterSeconds: 0 }   // ← zero
);

The indexed field is the absolute time at which the document should expire. The expireAfterSeconds: 0 means "delete the document as soon as the indexed time has passed." This is the most flexible mode — each document can have its own expiry date, and you can change a document's expiry by updating the field.

Mode 2: Fixed lifetime from insert

db.sessions.createIndex(
  { createdAt: 1 },
  { expireAfterSeconds: 3600 }   // ← all docs expire 1 hour after createdAt
);

Every document expires the same duration after the indexed field's value. Simpler to reason about, but every document has the same lifetime. Good for sessions, rate-limit windows, short-lived caches.

I use Mode 1 for NeoMail because each message can have a custom expiry. I'd use Mode 2 for a sessions collection where every session is one hour.

What TTL indexes are NOT good for

Three honest limitations:

  1. Sub-minute precision. The TTL monitor runs every 60 seconds. Documents expire within 60 seconds of their target, not at it. Fine for "delete after a week"; not fine for "delete this OTP exactly 30 seconds after sending."
  2. Cascading deletes. If messages have attachments as separate documents (or in S3), the TTL on messages doesn't touch the attachments. You either: also TTL-index the attachments table on the same field, or store everything as embedded subdocuments so they disappear together. Forgetting this is how teams end up with orphan attachment storage growing forever.
  3. Conditional expiry. "Delete only if the user is inactive" — TTL can't do that. The deletion criterion is purely "indexed time has passed." For conditional logic, you still need a cron.

Three gotchas

The first time I used a TTL index, I lost about two hours to one of these. Each is in the docs. None is obvious unless you've read them.

Gotcha 1: It has to be a Date object, not a Unix timestamp

MongoDB's TTL monitor only acts on BSON Date fields. Storing the expiry as a Unix timestamp number won't work — the documents are inserted, no error appears, and they're never deleted. Always:

// CORRECT
expiresAt: new Date(Date.now() + ms)

// SILENTLY WRONG
expiresAt: Date.now() + ms          // ← number, not Date
expiresAt: (Date.now() + ms) / 1000 // ← Unix seconds, also wrong

Gotcha 2: The first sweep can be slow

If you create a TTL index on a collection that already has millions of past-expiry documents, the first sweep is large — MongoDB will work through them at ~minutes per million on a small instance, blocking other write throughput. Mitigation: create the TTL index on a fresh collection, or batch-delete the existing past-expiry data manually first.

Gotcha 3: It runs on the primary only (in a replica set)

In a replica set, the TTL monitor only runs on the primary. The secondaries don't issue their own deletes — they replicate the deletes from the primary's oplog. This is correct behaviour, but it means a failover during a heavy TTL sweep can briefly delay deletions while the new primary takes over. Almost never an issue in practice; worth knowing for postmortems.

Why I reach for it more often than I used to

Before I knew TTL indexes well, the alternative was a nightly cron that scanned the collection and deleted past-expiry documents. That cron is:

A TTL index has none of these. It's enforced by the database itself. It survives application redeploys, server restarts, failovers, and library version changes. If the database is up, expiry works. If the database is down, you have bigger problems than slightly-delayed expiries.

NeoMail's full architecture — auth, state, sync — is on the case study page. The TTL index sits at line ~12 of the messages model file and has not been touched since.

Got data that needs to expire reliably? Gauravthakur1k@gmail.com · Services →