Queries
Build type-safe reads, aggregations, streams, and real-time subscriptions with the fluent query builder.
Query builder
Section titled “Query builder”Call repo.query() to get a FirestoreQueryBuilder<T, W>. It exposes a fluent, type-safe interface
for composing filters, ordering, projections, aggregations, pagination, streaming, and real-time
listeners. Chain the builder methods and then call a terminal method (get(), getOne(),
exists(), count(), paginate(), and so on) to execute the query.
const results = await orderRepo .query() .where('status', '==', 'pending') .where('total', '>', 100) .where('createdAt', '>=', startOfMonth) .orderBy('total', 'desc') .limit(50) .get();The chainable builder methods are:
where(field, op, value)— add a filter clause.select(...fields)— project only the named fields.orderBy(field, dir = 'asc')— sort results (required beforepaginate()).limit(n)— cap the number of documents returned.
Terminal methods that execute the query include get(), getOne(), exists(), count(),
totalCount(), sum(), average(), distinctValues(), paginate(), offsetPaginate(),
paginateWithCount(), stream(), onSnapshot(), update(), and delete(). There is no public
.startAfter() chaining — cursor pagination is handled entirely through
paginate(pageSize, cursor).
Performance note: Firestore charges per document read. Use limit() and pagination to control
costs on large collections — see Performance for the full cost model.
Filtering
Section titled “Filtering”Stack where() clauses to narrow results. All standard Firestore operators are supported, including
in, array-contains, and range comparisons.
const results = await userRepo .query() .where('age', '>', 18) .where('status', 'in', ['active', 'verified']) .where('tags', 'array-contains', 'premium') .get();Sorting
Section titled “Sorting”Chain orderBy() calls to sort by one or more fields. The direction defaults to 'asc'.
const sorted = await productRepo.query().orderBy('price', 'desc').orderBy('name', 'asc').get();Pagination
Section titled “Pagination”paginate(pageSize, cursor?) performs cursor-based pagination and returns
{ items, nextCursor, hasMore }. It requires at least one prior orderBy() call for a stable
cursor and throws if pageSize is less than or equal to 0. Pass the previous page’s
nextCursor to fetch the next page.
// Cursor-based pagination (recommended)// orderBy() is required for stable cursor paginationconst { items, nextCursor, hasMore } = await userRepo .query() .orderBy('createdAt', 'desc') .paginate(20);
// Next pageconst nextPage = await userRepo.query().orderBy('createdAt', 'desc').paginate(20, nextCursor);Use offsetPaginate(page, pageSize) for offset-based pagination. It is simpler but less efficient
on large datasets, since Firestore must scan and discard the skipped documents.
// Offset pagination (less efficient for large datasets)const page2 = await userRepo.query().orderBy('createdAt', 'desc').offsetPaginate(2, 20);paginateWithCount(pageSize, cursor?) combines paginate() and count() in a single call,
returning the same { items, nextCursor, hasMore } plus a total count of all matching documents.
It performs an extra aggregation read.
const { items, nextCursor, hasMore, total } = await productRepo .query() .where('inStock', '==', true) .orderBy('createdAt', 'desc') .paginateWithCount(20, lastCursor);
console.log(`Showing ${items.length} of ${total} products`);Aggregations
Section titled “Aggregations”Aggregations run server-side and are billed at a reduced rate compared to reading every matching document.
// Sum a numeric field across matching documentsconst totalRevenue = await orderRepo.query().where('status', '==', 'completed').sum('total');
// Average a numeric field across matching documentsconst avgRating = await reviewRepo.query().where('productId', '==', 'prod-123').average('rating');
// Count matching documentsconst activeCount = await userRepo.query().where('status', '==', 'active').count();
// Total collection count — ignores any accumulated where() clausesconst totalUsers = await userRepo.query().where('status', '==', 'active').totalCount();
// Existence checkconst hasOrders = await orderRepo.query().where('userId', '==', 'user-123').exists();
// Distinct values for a fieldconst categories = await productRepo.query().distinctValues('category');count() respects the query’s filters, whereas totalCount() counts the entire collection and
ignores any where() clauses on the builder. sum(field) and average(field) operate on numeric
fields, and distinctValues(field) returns the unique values for a field.
Selecting fields
Section titled “Selecting fields”Use select() to project only the fields you need. This reduces payload size but does not reduce
the document-read cost.
const userEmails = await userRepo .query() .where('subscribed', '==', true) .select('email', 'name') .get();Bulk query operations
Section titled “Bulk query operations”query().update(data) and query().delete() apply to every document matching the query and each
return the matched count.
Note: Lifecycle hooks do not run for
query().update()orquery().delete(). If you need per-document hooks (see Lifecycle hooks), iterate and call the single-document methods instead.
// Update all matching documents; returns the number of documents matchedconst updatedCount = await orderRepo .query() .where('status', '==', 'pending') .update({ status: 'processing' });
// Delete all matching documents; returns the number of documents matchedconst deletedCount = await userRepo.query().where('lastLogin', '<', oneYearAgo).delete();
// Delete matching documents with multiple filtersawait orderRepo .query() .where('status', '==', 'cancelled') .where('createdAt', '<', sixMonthsAgo) .delete();Streaming for large datasets
Section titled “Streaming for large datasets”stream() returns an async generator that yields matching documents one at a time, avoiding the
memory cost of loading an entire result set at once.
// Stream all users without loading them into memoryfor await (const user of userRepo.query().stream()) { await sendEmail(user.email); console.log(`Processed user ${user.id}`);}
// Stream with filtersfor await (const order of orderRepo.query().where('status', '==', 'pending').stream()) { await processOrder(order);}Performance cost: Streaming still reads every matching document, so you are charged for every document read. Use appropriate filters and limits.
Real-time subscriptions
Section titled “Real-time subscriptions”onSnapshot(callback, onError?) subscribes to live query results. It resolves to an unsubscribe
function — call it to stop listening. The callback receives the current set of matching documents on
every change.
// Subscribe to query resultsconst unsubscribe = await orderRepo .query() .where('status', '==', 'active') .onSnapshot( orders => { console.log(`Active orders: ${orders.length}`); updateDashboard(orders); }, error => { console.error('Snapshot error:', error); }, );
// Stop listening when doneunsubscribe();Cost warning: Real-time listeners charge you for every document that matches your query, plus additional reads when documents change. Use narrow filters and consider polling for less critical data.