Firestore query guide: build, filter, sort & optimize

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 sorts query results with the orderBy clause, applied to any field in the document set. Sorting citiesRef by population descending returns the largest cities first, but pairing orderBy with a where filter on a different field forces Firestore to build a composite index. Skip that step and the query throws an error at runtime, not at deploy time.

Firestore array-contains vs array-contains-any

array-contains matches documents where an array field holds one specific value; the array-contains-any operator matches documents where the array holds any of up to ten values, per Firebase's array membership rules. Query a regions array for 'Asia' with array-contains, or ['Asia', 'Europe', 'Americas'] at once with array-contains-any. Use the latter when one query should replace several separate reads.

Firestore composite index example

A composite index example: querying citiesRef where state equals 'California', ordered by population, needs an index that covers both fields together. Firestore's console suggests the missing index the first time the query fails at runtime, or you define it directly in firestore.indexes.json. Every new filter-plus-sort combination adds another index, and that is the main driver of index sprawl we see during migrations.

Firestore query vs collection group query

A standard query scopes to one collection path, like citiesRef, while a collection group query reads every subcollection sharing the same collection ID across the whole database. A museum subcollection nested under each city document, regardless of country, is only reachable in one call using 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?

Use get for a one-time read and the onSnapshot listener for data that has to update live in the UI. An onSnapshot listener re-fires on every write to the matched query, and each fire bills as a new read, which adds up fast on listeners left open. Detach listeners on unmount or navigation away, or read costs climb with every open tab.

Firestore query cost per read

Each document a query returns costs one read unit, priced per Google Cloud's Firestore pricing page, regardless of document size or field type. A query against citiesRef returning 200 documents bills 200 reads even if only the name or capital field is selected. Fan-out reads across large collections are the fastest way to blow a Firestore budget.

Not-in operator limitations

The not-in operator supports at most 10 comparison values, string or numeric, and cannot combine with an array-contains-any operator in the same query, per Firebase's query limitations documentation (Firebase Cloud Firestore Documentation). It also cannot pair with another inequality filter restriction on a different field, since Firestore allows only one inequality-type filter per query. Rewrite broad exclusion logic as an array-contains-any allowlist when not-in's limits block a query.

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.

We're Netguru

At Netguru we specialize in designing, building, shipping and scaling beautiful, usable products with blazing-fast efficiency.

Let's talk business