← Back to Blog
Guides

Firestore Performance Tuning: Index Strategy and Query Optimization

Firestore Performance Tuning: Index Strategy and Query Optimization

Your Firestore queries are slower than they should be—and it's probably your indexing strategy.

Most developers add indexes reactively: Firebase throws an error, you click the link, the index gets created, you move on. That works, but it leaves performance and cost on the table. Understanding why indexes matter—and how to design them intentionally—is the difference between a Firestore setup that scales gracefully and one that becomes a budget problem at growth.

This guide covers composite index design, common indexing mistakes, and how to measure the impact of your optimization work in production.


Why Composite Indexes Matter More Than Most Developers Realize

Firestore's query model is fundamentally different from SQL. Every query must be served by an index—Firestore doesn't do full table scans. For single-field queries, Firestore creates indexes automatically. For anything involving multiple where clauses or a combination of filtering and ordering, you need a composite index.

Here's where most developers underestimate the impact:

Query execution is index-bound, not data-bound. A composite index doesn't just make a query faster—it determines whether the query is possible at all. Firestore won't execute a multi-field query that isn't backed by a matching index. The cost and latency of a query are both tied directly to how efficiently the index serves it.

Index order determines which queries you can run. A composite index on (status ASC, createdAt DESC) is not the same as (createdAt DESC, status ASC). The field order affects which query shapes the index can serve. If you're filtering on status and ordering by createdAt, the filter field needs to come first.

Range filters have specific rules. You can only apply a range filter (<, <=, >, >=, !=) on one field per query. If you need range filtering on multiple fields, you'll need to restructure your data model or rethink the query approach—no amount of indexing will work around this constraint.

The Practical Implication

If you're building an admin dashboard that filters orders by status, date range, and customer region—and also needs to sort by value—you're looking at several composite indexes to serve those different query shapes efficiently. Planning this upfront, rather than discovering it at runtime, saves significant rework.


Common Indexing Mistakes That Tank Query Performance

1. Relying Solely on Auto-Generated Indexes

The auto-index link in Firebase error messages is convenient, but it only creates the exact index for the exact query that failed. If you have 10 different query shapes in your app, you'll accumulate 10 indexes—some of which may be redundant or suboptimal.

Better approach: audit your query patterns first, then design indexes that cover multiple queries where possible. A well-designed composite index can serve several related query shapes.

2. Ignoring Index Exemptions

Firestore indexes every field by default, including fields you never query on. For high-write collections with large documents—logs, events, analytics data—this means write operations are paying to maintain indexes on fields you'll never filter by.

Use index exemptions in your firestore.indexes.json to disable single-field indexing on fields that don't need it. For a document with 20 fields where you only query on 3, the write cost reduction can be substantial.

{
  "indexes": [],
  "fieldOverrides": [
    {
      "collectionGroup": "events",
      "fieldPath": "rawPayload",
      "indexes": []
    },
    {
      "collectionGroup": "events",
      "fieldPath": "debugMetadata",
      "indexes": []
    }
  ]
}

3. Not Accounting for Collection Group Queries

If you're querying subcollections across your entire database (e.g., all comments documents regardless of which post they belong to), you need collection group indexes. These are separate from single-collection composite indexes and easy to miss during planning.

4. Unbounded Queries on High-Cardinality Fields

A query without a limit() clause on a collection that grows over time will read more documents—and cost more—every day. Always use limit() in production queries, and paginate with cursors for anything that needs to display large result sets.

5. Over-Querying When Denormalization Would Help

Sometimes the right answer isn't a better index—it's restructuring your data. If you're consistently joining data from multiple collections for the same view, storing a denormalized copy of that data in a single document can eliminate multiple reads and simplify your indexes significantly.


Read/Write Cost Analysis: Measuring Optimization Impact

Optimization without measurement is guesswork. Here's how to track the actual impact of index changes.

Firebase Usage Console

The Firebase Console's usage tab shows read/write/delete counts and projected costs broken down by day. Before making index changes, screenshot or note your baseline daily read count. After deploying changes, compare the trend over the following week.

This is a lagging indicator—useful for confirming optimization worked, but not for debugging individual queries.

Firestore Query Explain (via Admin SDK)

The Admin SDK supports explain() on queries, which returns the number of index entries scanned and documents returned. A well-optimized query should have an index entries scanned count close to the documents returned count. A high ratio means the query is scanning many index entries to find a small result set—a signal that your index or query structure needs attention.

const { QueryExplainMode } = require("@google-cloud/firestore");

const query = db
  .collection("orders")
  .where("status", "==", "pending")
  .where("region", "==", "AU")
  .orderBy("createdAt", "desc")
  .limit(50);

const explainResults = await query.explain({
  analyze: true,
  explainMode: QueryExplainMode.ANALYZE,
});

console.log(explainResults.metrics);
// { resultsReturned: 50, executionStats: { indexEntriesScanned: 52, ... } }

An indexEntriesScanned count close to resultsReturned is what you want. If you're scanning 5,000 index entries to return 50 documents, there's a structural problem.

Cloud Monitoring

For production systems, set up Cloud Monitoring alerts on Firestore read counts. A sudden spike in reads is often a symptom of a missing index forcing a workaround, an unguarded query running in a hot loop, or a new feature that didn't account for query costs.


Real Production Examples: Before and After

Example 1: The Admin Dashboard Query

Scenario: An admin dashboard filters orders by status and assignedTo, sorted by createdAt descending. Initially, the app filtered in memory after fetching all orders for a given status.

Before: A query on status == 'open' returning ~2,000 documents, then filtering client-side. Every dashboard load: 2,000 reads.

After: A composite index on (status ASC, assignedTo ASC, createdAt DESC) allows the query to filter server-side before results are returned. Dashboard load for one assignee with 30 open orders: 30 reads.

Result: ~98% reduction in reads for that query path.

Example 2: The Activity Log Collection

Scenario: A high-write activityLogs collection capturing every user action. The collection has 15 fields per document, but the app only ever queries on userId and timestamp.

Before: All 15 fields indexed by default. Write cost: 15 index updates per document write.

After: Index exemptions set on 13 fields. Write cost: 2 index updates per document write.

Result: ~87% reduction in write costs for that collection, plus faster writes due to fewer index operations.

Example 3: The Pagination Fix

Scenario: A user-facing list showing the latest 20 items, with "load more" functionality. Originally implemented with offset-based pagination.

Before: Page 10 of results required reading 200 documents (to skip the first 180), then returning 20. Cost scales linearly with page depth.

After: Cursor-based pagination using startAfter(). Every page load reads exactly 20 documents regardless of depth.

Result: Consistent cost per page load, predictable latency at any pagination depth.


Validating Your Data After Optimization

Index changes and data restructuring—especially in production—carry risk. After any significant optimization work, you should verify that your actual data matches what you expect: that documents have the fields your new queries rely on, that denormalized copies are consistent with their sources, and that the restructuring didn't leave orphaned or malformed documents.

This is where a database management tool helps. After running a migration or restructuring a collection, being able to browse, filter, and inspect documents directly—without writing ad-hoc scripts—makes validation faster and less error-prone. If something looks wrong, having rollback capability (via snapshots taken before the migration) means you can recover without incident.

The goal of performance optimization isn't just faster queries—it's a production database you can confidently iterate on.


Summary

Firestore performance tuning comes down to a few core principles:

  • Design composite indexes intentionally, not just reactively when queries fail
  • Use index exemptions on high-write collections with fields you never query
  • Measure with query explain to confirm indexes are actually serving queries efficiently
  • Paginate with cursors, not offsets
  • Denormalize strategically when joining multiple collections for the same view repeatedly
  • Validate your data after restructuring, and have a rollback plan before you start

The developers who get the most out of Firestore aren't the ones who avoid these concerns—they're the ones who understand the cost model well enough to design around it from the start.


Firestorey is a Firestore database management tool built for production workflows. If you're restructuring collections as part of an optimization effort, Firestorey's bulk update and rollback features are designed for exactly that kind of work.