Firestore query guide: build, filter, sort & optimize
Contents
A slow Firestore query is rarely a Firestore problem, it's almost always a missing composite index or a query shape that forces a full collection scan disguised as a filter. Firestore rewards developers who think in indexes and cursors, not SQL habits.
This guide covers how to build, filter, sort, and paginate Firestore queries correctly, where compound conditions silently fail, and what real production read counts and latency look like when queries are structured right versus wrong.
Firestore query basics at a glance
Every Firestore query returns a QuerySnapshot, not raw documents. Firestore is a NoSQL document database with no server-side joins, so a query against a cities collection filtered on state or population hands back a QuerySnapshot wrapping each matching doc, with fields like name, capital, country, and museum typed as string, number, or array data.
Teams building this regularly run into query cost surprises: every document a query touches is a billed read, so a loose collection group query across regions can fan out reads fast.
This piece covers composite indexes, cursors, and the operators that keep that read count bounded.
How to build a basic firestore query
A basic Firestore query in the Node.js SDK is a where chain against a CollectionReference, executed with .get(), which resolves to a QuerySnapshot you iterate to pull individual DocumentSnapshot objects. There is no SQL parser and no server-side join. Every filter you add narrows the same underlying index scan.
Take the cities collection referenced in the prior example. To find cities in California above a population threshold:
const citiesRef = db.collection('cities');
const snapshot = await citiesRef.where('state', '==', 'CA').where('population', '>', 1000000).get();
snapshot.forEach(doc => {
console.log(doc.id, doc.data());
});
citiesRef is a typed reference, not a fetch. Nothing hits the network until .get() runs, and the returned QuerySnapshot carries .docs, .size, and .empty alongside the iterable you loop over.
Each DocumentSnapshot in that loop exposes .data() as a plain object, with name and capital typed as strings, population as a number, and fields like museum sometimes stored as an array, since Firestore documents in the same collection are not required to share a schema.
Cost follows document count, not query complexity. According to Google Cloud's Firestore pricing documentation, Firestore bills one read unit per document a query returns, a where chain that matches 50,000 documents across regions costs the same 50,000 reads as fetching each one individually. Add a .limit clause before you ship a query to production, not after a bill surprises you.
Firestore query operators explained
Firestore supports comparison, array-membership, and set-membership operators inside a where clause, each with its own index and read-cost behavior. The table below covers the operators you will actually reach for on a cities collection query.
| Operator | Example | Notes |
|---|---|---|
== |
citiesRef.where('country', '==', 'USA') |
Single equality filter, cheapest to index |
<, <=, >, >= |
citiesRef.where('population', '>', 1000000) |
Triggers the inequality filter restriction |
| array-contains | citiesRef.where('regions', 'array-contains', 'coastal') |
Matches one value inside an array field |
| array-contains-any | citiesRef.where('regions', 'array-contains-any', ['coastal', 'capital']) |
Matches up to 30 values, OR'd together |
| in | citiesRef.where('state', 'in', ['NY', 'CA']) |
Up to 30 values, OR'd equality |
| not-in | citiesRef.where('state', 'not-in', ['NY', 'CA']) |
Excludes up to 10 values; cannot combine with another not-in or array-contains-any on the same query |
The inequality filter restriction is the one that trips up most teams migrating from a relational schema: Firestore only allows range or inequality filters (<, <=, >, >=, !=) on a single field per query, per Firebase's query documentation. Ask for population > 1000000 and founded < 1900 in the same query and Firestore rejects it outright, not with a slow query, an error at write time.
This is the kind of thing that works in staging on a small dataset and fails the moment a second inequality field gets added in production, forcing a schema rework mid-sprint.
Each operator combination beyond a simple equality chain usually needs its own composite index, and index sprawl is the real cost, not the read.
That cost model is the natural next question: what do these reads actually bill.
Firestore array-contains vs array-contains-any
Array-contains matches documents where a single array field holds one specific value; array-contains-any matches documents where that field holds at least one value from a supplied list. On a cities collection with a regions array field, citiesRef.where('regions', 'array-contains', 'Asia') returns every document tagged Asia, while the array-contains-any operator lets you pass ['Asia', 'Europe'] and get documents matching either.
The tradeoff is disjunction size, not read cost per document. Per Firebase's query documentation, array-contains-any supports up to 30 comparison values per query, and you cannot combine it with a second array-contains-any or in filter on a different field in the same query (Firebase Cloud Firestore Query Documentation).
| Operator | Matches | Limitation |
|---|---|---|
| array-contains | one value in array field | single value only |
| array-contains-any | any of up to 30 values | one disjunctive filter per query |
Hit that 30-value ceiling on a field with high cardinality, like tagging by country or state, and the fix is splitting the request into parallel queries merged client-side rather than trying to widen the array-contains-any list.
Not-in operator limitations
The not-in operator on a Firestore query accepts at most 10 comparison values and cannot be combined with another inequality filter restriction on a different field, per Firebase's query limitations documentation (Firebase Cloud Firestore Query Documentation). That second constraint trips up teams more than the value cap.
If you filter a cities collection with citiesRef.where('population', 'not-in', excludedList), you cannot also add where('type', '<', 'town') in the same query, because Firestore only permits one inequality field per query. Firestore rejects that combination outright at query time rather than letting it run slow, so the fix is restructuring the filter, not rebuilding an index.
Not-in also excludes documents missing the field entirely, which surprises teams migrating string-typed name or museum fields from a legacy SQL schema where null meant something different.
Compound queries and when you need a composite index
A Firestore query needs a composite index whenever it combines an equality filter on one field with a range or sort on a different field, or stacks range conditions across fields. Firestore builds single-field indexes automatically, but any compound query that layers a second condition on top of an inequality filter restriction falls outside that automatic coverage, per Firebase's indexing documentation.
Take a cities collection where each document holds country, state, population, capital, and type (city, capital, museum-town, whatever taxonomy you're using). A query filtering country == 'USA' and ordering by population runs fine on the default index. Add a second range clause, say population > 500000 and state == 'active', and Firestore rejects the query until you define a composite index covering both fields, in the exact order the query specifies.
Index sprawl is the real cost here, not just query latency. Every composite index you add for a citiesRef query pattern doubles as a write cost: Firestore updates every matching index on each document write, per Google Cloud's Firestore pricing page. A collection with a dozen composite indexes pays that write tax on every single document mutation, regardless of whether a given query ever runs.
Letting ad hoc query patterns generate index definitions for months is how a project ends up with a firestore.indexes.json full of composite indexes covering queries the app no longer runs. That matters because Firestore caps composite indexes at 200 per database without billing enabled, and 1,000 with billing enabled (Firebase Firestore quotas and limits) — auditing and pruning unused indexes periodically is cheaper than hitting that ceiling mid-launch.
Firestore composite index example
A composite index example is easiest to reason about with a concrete cities collection, where each document stores name, state, country, population, capital, museum, and region as typed fields (strings, numbers, booleans).
Define citiesRef = db.collection('cities'), then run citiesRef.where('country', '==', 'usa').where('population', '>', 1000000). That query throws FAILED_PRECONDITION on first execution, because an equality filter on country is stacked with a range filter on population, exactly the pattern that falls outside automatic single-field coverage.
Firestore's error message includes a direct link to pre-fill the index. You can also define it by hand:
{
"indexes": [
{
"collectionGroup": "cities",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "country", "order": "ASCENDING" },
{ "fieldPath": "population", "order": "ASCENDING" }
]
}
]
}
Deploy it with firebase deploy --only firestore:indexes, or build the same composite index manually in the Firebase Console under Firestore Database > Indexes > Composite, useful when you want to review field order before a migration ships to production.
Each composite index also adds a write cost: Firestore updates every matching index entry on every document write, which billing calculates as additional index entry writes per Google Cloud's Firestore pricing documentation. An unmanaged set of composite indexes that's grown past what anyone can account for is exactly the kind of thing that lets a missing index slip through a schema change unnoticed, until a production query starts failing under load.
How to sort firestore query results
OrderBy sorts a Firestore query by one or more fields, and it composes directly with where clauses on the same firestore collection referenced by citiesRef. Call citiesRef.where('country', isEqualTo: 'usa').orderBy('population', descending: true).limit(10) and Firestore returns the ten largest US cities by population as a QuerySnapshot, read-billed per document returned, not per document scanned.
Sort direction rules matter more than they look. If you filter on an inequality (>, <, >=, <=) on a field, your first orderBy must sort on that same field, an inequality filter restriction documented explicitly in Firebase's docs. Add a second orderBy('name') for tie-breaking and Firestore needs a composite index covering country, population, and name in that exact order.
A two-field compound sort costs more than a single-field orderBy('name') query at equivalent document counts, because Firestore has to walk a wider composite index instead of a single-field one, and that index has to stay in sync with every write that touches either field.
Pair orderBy with limit and a query cursor for pagination; skipping the cursor and re-running orderBy with offset re-reads every prior document, which drives up cost as the collection grows.
How to paginate firestore query results with cursors
A query cursor marks a specific document's position within a query's sort order, and startAfter uses that position to fetch the next page without rescanning documents Firestore already returned. Pair it with limit and you get true cursor-based pagination on citiesRef.
const first = await citiesRef.orderBy('population', 'desc').limit(25).get();
const last = first.docs[first.docs.length - 1];
const next = await citiesRef.orderBy('population', 'desc').startAfter(last).limit(25).get();
startAfter(last) accepts the last document snapshot from the previous page and resumes exactly where it left off. endBefore works the same way for reverse pagination, useful for a "previous page" control in a UI backed by the collection cities data set.
Avoid the offset method for deep pagination. Firestore still reads and bills for every skipped document before returning your page, so offset(500).limit(25) costs 525 reads per Firebase's pagination documentation (Firebase Cloud Firestore Pricing Documentation). Cursor pagination bills only for the 25 documents you actually get back.
Offset-style pagination on a collection with deep page depth makes read counts climb linearly with page number, not with result set size, which is easy to miss until the bill shows it. Cursor state also needs to travel with the client, not the server, since Firestore's snapshot listener and QuerySnapshot objects are not designed to hold pagination state between requests.
One index-management pitfall worth flagging: switching sort order mid-migration (say, from name to population) without a matching composite index in place causes cursor queries to fail silently in some SDKs rather than error clearly, so validate index coverage before shipping pagination changes.
Get vs onSnapshot: Which should you use?
Use get for a one-time read and onSnapshot when the UI needs to react to changes without a manual refresh. The two aren't interchangeable performance-wise: get returns a single QuerySnapshot and stops billing you the moment it resolves, while an onSnapshot listener stays open and re-bills for every document that changes in the result set, not just the initial page.
| get | onSnapshot | |
|---|---|---|
| Read pattern | one-time snapshot | continuous listener |
| Billing | one read per document returned | initial reads plus one read per changed document, for the life of the listener |
| Offline persistence | serves last cached result if offline, no live updates after | pushes a cached QuerySnapshot instantly, then syncs live once the client reconnects |
| Best fit | reports, admin scripts, one-shot dashboard loads | chat, presence, live counters, anything on citiesRef that needs to reflect writes from other clients |
Offline persistence is where teams get surprised. With persistence enabled, onSnapshot on a cities collection fires immediately from the local cache, even mid-flight, then fires again on reconnect with server data, so your callback needs to tolerate duplicate-looking snapshots rather than assume each docs array is a fresh state.
The recurring pitfall: an unbounded onSnapshot listener on a large collection, left without a where filter or limit, keeps re-billing on every unrelated write to that collection. According to Firestore's pricing documentation, read units are billed per document per query execution, so a chatty listener on a high-write collection can generate read costs an equivalent get() polling pattern would never hit.
Scope listeners the same way you'd scope any query: filter first, listen second.
Querying across subcollections with collectionGroup
A collection group query lets you read across every subcollection that shares the same collection ID, regardless of which parent document it sits under. If every country document in a regions collection has a cities subcollection, db.collectionGroup('cities') returns matching city documents from all of them in one round trip, instead of one query per parent document.
const citiesRef = db.collectionGroup('cities');
const snapshot = await citiesRef.where('capital', '==', true).where('population', '>', 500000).get();
Each returned document can carry a name, state, country, population, and even a museum count field, since collectionGroup does not care about document shape, only collection ID and path segment.
Here's the part competitors' NoSQL guides skip: a collection group query needs its own composite index, scoped to "Collection group" in the Firestore console, not a single-collection index. Firebase's collection group documentation states this scope requirement explicitly for any filter beyond a single equality check.
This is an easy way to stall a deploy: add a second where clause to an existing group query, assume the single-collection index already covers it, and ship a change that fails at runtime with a missing-index error rather than at build time.
The fix, and the habit worth keeping, is to build the group-scoped index before the query goes near production, not after the first FAILED_PRECONDITION from a live QuerySnapshot.
Firestore query vs collection group query
A scoped query and a collection group query differ in fan-out, not in syntax. Querying citiesRef under one regions/europe document reads only that country's cities collection; a collection group query on cities reads every matching collection across every region document, regardless of state, population, or capital status.
Firestore bills one read unit per document returned no matter which query type you run, so Google Cloud's Firestore pricing documentation means a collection group query spanning fifty countries' cities collections costs the same as fifty scoped queries combined, the round trips disappear, the read cost does not.
Each collection group query also needs its own composite index entry. Keep adding fields to documents without pruning the indexes that back them, and write latency creeps up as index sprawl grows, since every write has to update every index covering that document's collection.
| Scoped query | Collection group query | |
|---|---|---|
| Scope | One cities collection under a known parent doc | Every cities collection across all regions documents |
| Fan-out | None | High, by design |
| Index | Standard per-collection composite index | Dedicated collection group index required |
| Netguru's default | Parent document ID known, single-country lookups | Cross-country aggregation, reporting, search |
Use a scoped query when you already have the parent document ID. Reach for a collection group query only when the read genuinely needs data from every parent at once.
Aggregation queries: Count, sum, and average without full reads
An aggregation query (count, sum, or average) returns a single number without transferring the underlying documents, which is the direct fix for the fan-out read cost we just described on collection group queries.
Run citiesRef.count().get() against the cities collection and Firestore scans the matching index entries server-side, then returns one result set instead of streaming every document's name, population, and country fields back to the client. According to Firebase's aggregation queries documentation, count queries are billed based on the number of index entries scanned, not the number of documents returned, and sum and average work the same way as of the Firestore server SDK.
This replaces a common anti-pattern: pulling an entire collection into memory just to count entries with state == 'capital' or sum population across a country, a pattern that gets expensive fast once it's running inside a snapshot listener re-fetching on every write.
Swapping to a bounded aggregation query, without touching the composite index already backing the equivalent where query, cuts billed reads significantly. Aggregation still respects existing inequality filter restrictions and index requirements, so plan the composite index the same way you would for the underlying filtered query.
Query limitations, cost, and anti-patterns
Firestore restricts inequality filters to a single field per query, per Firebase's query limitations documentation. Try to filter population > 1000000 and founded_year < 1900 in the same query against citiesRef, and Firestore rejects it before it ever hits an index. The workaround is usually a smaller inequality filter plus client-side or Cloud Function post-filtering, not a bigger composite index.
The not-in operator carries its own ceiling: at most 10 comparison values, and it cannot combine with array-contains-any in the same query, per the same Firebase docs. Building a country not-in [...] filter against a large regions list will hit that 10-value cap in production, forcing a fallback to != chains that each need their own composite index.
Cost follows document reads, not query complexity. Every document a query touches counts against Firestore's per-document-read billing, listed on Google Cloud's Firestore pricing page, whether or not the field you filtered on ends up in the result set. A collection group query across every subcollection reads every match, and an unbounded onSnapshot listener re-reads on every write, two of the most common ways a query silently gets expensive.
Index sprawl is the other recurring pitfall. A collection that accumulates a composite index for every filter-and-sort combination the app has ever used ends up carrying several redundant ones, especially once cursor-based pagination replaces an older offset-based query that needed its own index shape.
Firestore's index-merge behavior covers simple cases, but composite indexes still need active pruning, or the write path slows down as each doc write updates every stale index entry.
Firestore query cost per read
Firestore bills every query by document read, not by bytes returned. Each document in a QuerySnapshot counts as one read unit whether you fetch just name and capital or the full record, per Google Cloud's Firestore pricing page.
A query against citiesRef filtering state == 'California' and sorting by population reads the merged composite index, not a cheap single-field scan. Firestore performs an index merge under the hood, and every matching document still costs one read unit regardless of index count.
Aggregation queries break that rule. A count, sum, or average query returns one billed read no matter how many documents in the collection match, per Firebase's aggregation query documentation. Swap a fallback count-by-fetch pattern for a native aggregation query on a collection with tens of thousands of documents, and the read cost drops from one read per document to a single read, several orders of magnitude on a large enough collection.
One index-management habit worth keeping: every composite index added for a new filter or sort field also gets written on every document write, so index sprawl inflates write cost long before read cost becomes the bottleneck.
FAQ: Firestore query
How do you sort firestore query results?
Firestore array-contains vs array-contains-any
['Asia', 'Europe', 'Americas'] at once with array-contains-any. Use the latter when one query should replace several separate reads.
Firestore composite index example
Firestore query vs collection group query
collectionGroup('museum'). Reach for collection group queries when data is sharded by parent document but needs one combined read.
Firestore onSnapshot vs get, which should I use?
Firestore query cost per read
Not-in operator limitations
Optimize your firestore queries with expert help
Composite index sprawl, unbounded onSnapshot listeners, and fan-out reads on a citiesRef collection rarely surface in a code review. They surface in a Cloud Billing invoice after the cities collection passes a few million documents.
If your query costs or read latency are climbing faster than your document count, talk to our team about query design and index strategy that stays fast as your collection grows. For advanced search infrastructure needs beyond native Firestore querying, our Elasticsearch development services can help you build high-performance search on top of your data.