Firestore Index Auditing and the limitToLast Gotcha


Firestore composite indexes are easy to forget about until you hit a runtime error. Here’s how to systematically audit them, and a subtle gotcha with limitToLast() that can catch you off guard.


When Do You Need a Composite Index?

Firestore automatically creates single-field indexes. You need a composite index when a query combines:

  • .where() on one field + .orderBy() on a different field
  • Multiple .where() clauses on different fields
  • Inequality operators (!=, <, >, etc.) combined with other filters or ordering

Auditing: Repo Indexes vs Production

If you manage indexes as JSON files in your repo (which you should), periodically compare them against what’s actually deployed.


Step 1: Export current production indexes:

firebase use your-production-project
firebase firestore:indexes > current_prod.json

Step 2: Compare each index in your repo files against current_prod.json. Match by collectionGroup, queryScope, and all fields (fieldPath + order/arrayConfig).


Step 3: Check for discrepancies:

  • Missing in prod: indexes defined in repo but not deployed — these need to be deployed
  • Extra in prod: indexes in prod but not in repo — these may be orphaned

Auditing: Codebase Queries vs Index Files

Search your codebase for all Firestore queries that might need composite indexes:

# Find composite query candidates
grep -rn "\.where\(" --include="*.ts" --include="*.tsx" | grep -v node_modules
grep -rn "\.orderBy\(" --include="*.ts" --include="*.tsx" | grep -v node_modules

For each query that combines .where() with .orderBy() (on different fields), verify a matching composite index exists in your index files.


The limitToLast Gotcha

This is the subtle one. Consider this query:

collection
  .where("type", "in", ["paid", "order"])
  .orderBy("createdAt", "asc")
  .limitToLast(20);

You might think this needs an index with type ASC + createdAt ASC. But it actually needs type ASC + createdAt DESC.


Why? limitToLast() works by reversing the query direction internally. Firestore actually executes:

.where("type", "in", ["paid", "order"])
.orderBy("createdAt", "desc")   // ← flipped!
.limit(20)

Then the SDK reverses the results back to ascending order on the client side.


So if your index only has createdAt ASC, you’ll get a runtime error asking you to create a composite index. The Firestore error will include a URL with the correct index definition — but the URL is a base64-encoded protobuf that might not render correctly if copied from device logs or screenshots.


How to decode the URL: The create_composite parameter is a base64-encoded protobuf. You can decode it to see the exact fields and directions:

echo 'BASE64_STRING_HERE' | base64 -d | xxd

Look for the human-readable field names in the hex dump. The direction bytes tell you ASC (01) or DESC (02).


Fix

Add both index variants if your codebase uses both limit() and limitToLast():

{
  "indexes": [
    {
      "collectionGroup": "MESSAGES",
      "queryScope": "COLLECTION",
      "fields": [
        { "fieldPath": "type", "order": "ASCENDING" },
        { "fieldPath": "createdAt", "order": "ASCENDING" }
      ]
    },
    {
      "collectionGroup": "MESSAGES",
      "queryScope": "COLLECTION",
      "fields": [
        { "fieldPath": "type", "order": "ASCENDING" },
        { "fieldPath": "createdAt", "order": "DESCENDING" }
      ]
    }
  ]
}

Tip: name in Indexes

You might see __name__ in Firestore index definitions. Firestore implicitly appends __name__ as a tiebreaker to every composite index. Adding it explicitly creates a technically different index that behaves identically at query time. If your repo has __name__ but production doesn’t (or vice versa), deploying will create duplicate indexes. Keep them consistent — generally, omit __name__ and let Firestore handle it implicitly.