Firestore Query and Index Gotchas, Cataloged

Firestore's query model is deliberately constrained: every query must be served by an index, and the constraints are enforced at query time rather than at design time. Teams coming from SQL read this as "Firestore can't do queries." The truth is narrower and more useful — Firestore can do exactly the queries you modeled for, and the failure modes cluster into a handful of patterns you can learn once.

Inequality filters are the sharp edge

A single query supports range or inequality conditions (<, <=, >, >=, !=, not-in) with ordering constraints that surprise people: your first orderBy must be on the field with the inequality. This works:

query(orders,
  where('createdAt', '>=', start),
  orderBy('createdAt', 'desc'),
  orderBy('total', 'desc'))

But you cannot filter by createdAt >= start and order primarily by total — the index model can't serve it. The practical consequence: "show recent orders sorted by amount" is not one query. It is either a composite-field design decision (bucket by day, order within the bucket) or a client-side sort of a bounded result set. Decide which at modeling time, not in a hotfix.

!= and not-in carry a quieter cost: they match only documents where the field exists, and they still consume reads proportional to what they scan. A status != 'archived' over a large collection is usually better modeled as an active: true flag you can equality-filter.

Composite indexes: created on demand, forgotten at scale

Firestore's error message with the index-creation link is famously helpful, and it produces a familiar anti-pattern: indexes accumulate one console click at a time, never recorded, until a new environment (a staging project, the emulator, a disaster-recovery restore) is missing half of them.

Keep firestore.indexes.json in the repository as the source of truth and deploy it with the rules:

firebase firestore:indexes > firestore.indexes.json   # export what exists today
firebase deploy --only firestore:indexes

Then audit the file occasionally. Every composite index costs write amplification — each document write updates every index that covers it — so indexes serving queries the app no longer runs are pure write overhead.

Index fan-out on array and map fields

Two field shapes generate outsized index entries:

  • Arrays — each element gets an index entry per relevant index. An array-contains query over a tags array of 3 items is fine; a document carrying 500 tags pays 500 index-entry writes on every update.
  • Maps — every key of a map field is indexed individually by default. A free-form map (say, per-user reaction counts keyed by uid) silently generates an index entry per key per write.

The fix for both is index exemptions. In firestore.indexes.json, fieldOverrides can disable automatic indexing for a field you never query:

{
  "fieldOverrides": [{
    "collectionGroup": "posts",
    "fieldPath": "reactions",
    "indexes": []
  }]
}

For high-write documents, exempting the fields you don't query is one of the cheapest performance wins available — it cuts write latency and cost with zero application changes.

The 1 write/second/document rule of thumb

Sustained writes above roughly one per second to a single document produce contention and failed transactions. The classic victims are counters and "last activity" fields on a shared document. Solutions, in order of preference: don't store the aggregate at all (compute it in a scheduled rollup), shard the counter across N subdocuments and sum on read, or move the hot field out of the contended document so unrelated updates stop colliding.

Pagination: cursors, not offsets

Firestore has no offset-based pagination worth using — offset() in server SDKs still bills reads for every skipped document. Use cursor pagination with startAfter(lastDocSnapshot), and make the ordering deterministic by adding a tiebreaker field (usually the document ID) to the orderBy. Without the tiebreaker, two documents with equal sort values can make pages overlap or skip.

The subtle bug: building the cursor from field values (startAfter(lastCreatedAt)) rather than the snapshot. If two documents share that timestamp, the cursor lands ambiguously. Pass the snapshot; the SDK encodes the full index position.

Collection-group queries and rules

collectionGroup('invoices') queries every subcollection named invoices across the database — powerful, and a common security-rules surprise. The rules that govern a collection-group query are matched by path pattern, so a rule written as match /users/{uid}/invoices/{id} does not automatically permit (or constrain) the group query; you need a match /{path=**}/invoices/{id} block with conditions that hold for every parent path. Before shipping a collection-group query, re-run your rules test suite with it — this is precisely the case where list permissions diverge from get.

The habit that prevents most of this

Write the query list before the data model. Every screen, every export, every scheduled job: what does it need to read, filtered and ordered how, at what volume? Model the collections so each query is an equality-plus-one-range shape with a recorded index, and treat any query you can't express that way as a modeling decision to make explicitly — denormalize, precompute, or move that workload to a store built for ad-hoc queries. Firestore punishes improvised queries and rewards planned ones; the difference is one afternoon of design work.