+1 (762) 572-1994

Firestore TTL Policies: Expiring Data Without a Cron Job or a Surprise Bill

Most Firestore apps accumulate data nobody reads: sessions, OTP codes, rate-limit counters, notification inboxes, soft-deleted rows. The usual cleanup is a scheduled function that queries for old documents and deletes them in batches. It works. It also costs a read per document, a delete per document, and a function that eventually times out.

A TTL policy replaces that function. It has sharp edges. This post lists them.

What a TTL policy is

You pick a collection group and a timestamp field. Firestore deletes documents whose field value is in the past. No function, no scheduler, no read charge for finding them.

gcloud firestore fields ttls update expireAt \
  --collection-group=sessions \
  --enable-ttl

Write the field when you create the document:

import { Timestamp } from "firebase-admin/firestore";

const DAY_MS = 24 * 60 * 60 * 1000;

await db.collection("sessions").doc(id).set({
  uid,
  createdAt: Timestamp.now(),
  expireAt: Timestamp.fromMillis(Date.now() + 30 * DAY_MS),
});

The field must be a Timestamp. A number or an ISO string is silently ignored. Documents with the wrong type never expire, and nothing warns you.

Edge 1: expiry is not deletion

Deletion is a background process. Expired documents commonly disappear within a day, but the guarantee is "eventually", not "at the expiry time". Until then the document is readable and queryable.

If expiry carries meaning, such as a session or an invite link, enforce it at read time. In rules:

match /sessions/{id} {
  allow read: if request.auth.uid == resource.data.uid
              && resource.data.expireAt > request.time;
}

In queries, add where("expireAt", ">", Timestamp.now()). That needs the field indexed, which leads to the next point. Treat TTL as storage cleanup, never as an access-control mechanism.

Edge 2: TTL deletes ignore your rules

The deletion is performed by the service. Security rules are not evaluated. This is fine, but it means a rule like allow delete: if false does not protect a document from expiring. Anyone who can write the expireAt field can shorten a document's life. Make sure your update rules pin it:

allow update: if request.resource.data.expireAt == resource.data.expireAt;

Add a denial test to your emulator suite that tries to move expireAt to the past. Test the denial, not just the grant.

Edge 3: subcollections are not deleted

TTL deletes the document, not its subcollections. Those become orphans: still stored, still billed, and no longer reachable by path from a parent that exists.

Two fixes:

  • Put a TTL policy on the child collection group too, with its own expireAt set to the parent's value.
  • Flatten. If the child data only ever matters with the parent, store it in the parent document or in a top-level collection with its own TTL.

Audit this before enabling a policy on any collection that has children. Use the Firebase console to look for documents shown in italics. Those are paths with no parent document.

Edge 4: the index on the TTL field

A TTL field gets a single-field index by default. For a field that is always now + constant, new writes land at the end of the index range. That is the same hotspot shape described in our post on write contention. At low volume you will not notice. At hundreds of writes per second you will.

If you do not query on expireAt, add a single-field index exemption for it. TTL deletion still works without the index. If you do enforce expiry in queries, keep the index and add jitter to the value, for example plus or minus a few minutes, so writes spread out.

Edge 5: deletes fire triggers and cost money

TTL deletions are billed as document deletes. You skip the read and the function runtime, but you do not skip the delete charge. Do the arithmetic before you pick a retention window:

docs/day         = 2,000,000
retention        = 7 days
steady-state docs = 14,000,000
deletes/day      = 2,000,000  -> one delete line item per doc written

The policy does not make retention free. It makes it cheaper than the cron job. Shorter retention lowers storage, not delete count: every document you write is deleted once either way. If the data has no value, the cheapest fix is to not write it. Rate-limit counters in particular belong in memory or in a different store.

Deletes also fire onDocumentDeleted triggers. If you have one that cascades cleanup, a bulk expiry will invoke it once per document, and it needs to be idempotent. See our post on trigger retries. Cap its concurrency with maxInstances so a large expiry batch does not become a retry storm against a downstream API.

Edge 6: existing data and backfills

Enabling a policy applies to documents already in the collection group. Any document with a past expireAt is eligible immediately. Before enabling it on a live collection:

  1. Count what would match: count() on where("expireAt", "<", now). An aggregation count is billed at a small fraction of a read per matched index entry, so it is cheap.
  2. Check the count against what you expect. A bad backfill that wrote expireAt in seconds instead of milliseconds will put every document in 1970.
  3. Export first if the data matters. PITR will recover deletions within its window, but you should not have to find that out in an incident.

Checklist

  • expireAt is a Timestamp, verified with a query for documents where it is not.
  • Expiry that matters is enforced in rules or queries, not by TTL.
  • Rules pin expireAt on update, with a denial test.
  • Child collections have their own policy or have been flattened.
  • Index exemption set, or jitter added, for high-write collections.
  • Delete triggers are idempotent and capped.
  • The count of matching documents was checked before enabling.

TTL is a good feature. Use it for what it is: lazy, rules-blind garbage collection. Read the delete line on your invoice a week after enabling it. If it matches your arithmetic, you are done.