Performance
How FlintFire operations map to Firestore billing, what each call costs under the hood, cost-optimization patterns, and rough latency benchmarks.
Understanding Performance Costs
Section titled “Understanding Performance Costs”Firestore Pricing Model
Section titled “Firestore Pricing Model”Firestore charges for:
- Document reads - Every document returned from a query
- Document writes - Every create, update, or delete
- Document deletes - Separate charge from writes
- Storage - Data stored in your database
- Network egress - Data transferred out of Google Cloud
Operation Costs
Section titled “Operation Costs”| Operation | Cost | Notes |
|---|---|---|
getById() |
1 read | Single document lookup |
getMany(N ids) |
N reads, one round trip | Input order; null marks missing |
query().limit(100).get() |
100 reads | Reads up to 100 documents |
query().get() |
1 read per result | Charges for every matched document |
query().whereFilter(…) |
1 read per matched document | OR fans out into a scan per disjunct |
query().count() |
1 read per 1000 docs | Aggregation query (cheaper than fetching) |
query().aggregate(spec) |
1 aggregation request (up to 5 aliases) | One round trip for count+sum+average |
create() |
1 write | Single write operation |
bulkCreate(100) |
100 writes | Batched but still counts as 100 writes |
bulkWrite(N ops) |
N writes | Still 1 write per op; win is parallelism + per-item failure isolation, not cost |
update() |
1 write | Even if updating one field |
delete() |
1 read + 1 delete | Existence pre-read, then the delete |
upsert() |
1 read + 1 write | Existence pre-read chooses create vs update |
bulkDelete(N ids) |
N reads + 1 delete per surviving doc | One getAll existence pre-read, then the batch |
recursiveDelete(id) |
1 delete per document in the subtree | Target + all descendants |
recursiveDeleteCollection() |
1 delete per document across the collection and all descendants | Entire repository collection + nested subcollections |
query().update() |
1 read per match + 1 write per match | Query, then batched writes |
query().delete() |
1 read per match + 1 delete per match | Query, then batched deletes |
onSnapshot() |
1 read per doc initially + 1 read per change | Real-time listener costs |
What Happens Under the Hood
Section titled “What Happens Under the Hood”Simple Query
const users = await userRepo.query().where('status', '==', 'active').limit(10).get();- Firestore executes the query with the
statusfilter andlimit(10) - Returns up to 10 documents
- Cost: 10 reads (or fewer if less than 10 matches)
Composite (OR) Query
const posts = await postRepo .query() .whereFilter(f => f.or(f.where('status', '==', 'published'), f.where('pinned', '==', true))) .get();- Firestore normalizes the composite filter into disjunctive form — one disjunct per OR branch
- Each disjunct is evaluated against an index, and the results are combined into a single result set (a document matching two branches is returned once)
- Cost: 1 read per document in the result, the same as any query — but the index requirements grow with the branches, and the server rejects a filter that normalizes to more than 30 disjunctions
Keep the branch count small. An in filter with N values inside an OR branch expands to N
disjunctions, so a handful of branches can cross the cap. When the branches are stable and known
ahead of time, a single denormalized flag (see
Data Modeling) is cheaper to index and query than
a wide disjunction.
Pagination
const { items, nextCursor, hasMore } = await userRepo .query() .orderBy('createdAt', 'desc') .paginate(20, cursor);- Requires at least one
orderBy()clause for stable paging - If
cursorprovided, decodes cursor and fetches that document first (1 read) - Executes query with
limit(pageSize + 1)to detect whether more pages exist - Returns up to
pageSizeitems plushasMoreandnextCursor - Cost (page size 20): up to 21 query reads (+1 extra cursor lookup read when cursor provided)
Bulk Create
await userRepo.bulkCreate(users); // 500 users- Validates all 500 documents against schema
- Splits into batches of 500 operations (Firestore limit)
- Commits each batch sequentially
- Cost: 500 writes
Bulk Write (bulkWrite)
await userRepo.bulkWrite(ops); // e.g. 600 mixed create/update/delete- Validates each operation independently; failures become per-item results
- Enqueues through one Admin SDK
BulkWriter, then closes it - Cost: still 1 write per operation — the win is parallelism, retries on transient statuses, and failure isolation (one bad row does not abort the rest), not a lower write bill
Query Update
await orderRepo.query().where('status', '==', 'pending').update({ status: 'shipped' }); // 150 matches- Executes query to find matching documents (150 reads)
- Batches updates in groups of 500
- Commits all updates
- Cost: 150 reads + 150 writes
Delete
await userRepo.delete(userId);- Fetches document to verify existence (1 read)
- Deletes the document (1 delete)
- Cost: 1 read + 1 delete
Transaction
await accountRepo.runInTransaction(async (tx, repo) => { const from = await repo.getInTransaction(tx, 'acc-1'); const to = await repo.getInTransaction(tx, 'acc-2');
await repo.updateInTransaction(tx, 'acc-1', { balance: from.balance - 100 }); await repo.updateInTransaction(tx, 'acc-2', { balance: to.balance + 100 });});- Reads both documents within transaction (2 reads)
- Locks both documents until transaction completes
- Commits both updates atomically (2 writes)
- Cost: 2 reads + 2 writes
Cost Optimization Tips
Section titled “Cost Optimization Tips”-
Prefer
getMany(ids)over N ×getByIdorwhereId('in', …)for id lookups// ✅ One BatchGetDocuments RPC — input order, null marks missing, no 30-value capconst rows = await userRepo.getMany(ids);// ❌ N round tripsconst rows = await Promise.all(ids.map(id => userRepo.getById(id)));// ❌ whereId('in') caps at 30, returns document-name order, silently drops missing idsconst rows = await userRepo.query().whereId('in', ids).get();Callers reading many thousands should still chunk themselves — chunking trades away the single-snapshot guarantee. There is no library-enforced hard limit.
-
Use
count()/aggregate()instead of fetching when you only need aggregates// ✅ Efficient — one aggregation requestconst total = await userRepo.query().where('status', '==', 'active').count();// ✅ Efficient — count + sum + average in ONE requestconst stats = await orderRepo.query().where('status', '==', 'completed').aggregate({orders: { kind: 'count' },revenue: { kind: 'sum', field: 'total' },avgOrder: { kind: 'average', field: 'total' },});// ❌ Expensiveconst users = await userRepo.query().where('status', '==', 'active').get();const total = users.length; -
Limit query results
// Always add reasonable limitsawait userRepo.query().limit(100).get(); -
Use
exists()for presence checks// ✅ Reads at most 1 documentconst hasOrders = await orderRepo.query().where('userId', '==', userId).exists();// ❌ Reads all matching documentsconst orders = await orderRepo.query().where('userId', '==', userId).get();const hasOrders = orders.length > 0; -
Select specific fields to reduce bandwidth
// Reduces network transfer (still charges for full document read)const emails = await userRepo.query().select('email').get(); -
Be cautious with real-time listeners
// Charges for every document on initial load + every change// Use narrow filtersawait orderRepo.query().where('userId', '==', userId).where('status', '==', 'active').onSnapshot(callback);
Performance Benchmarks
Section titled “Performance Benchmarks”Based on testing with Firebase Admin SDK:
| Operation | Documents | Time | Notes |
|---|---|---|---|
create() |
1 | ~50ms | Single document write |
bulkCreate() |
100 | ~300ms | Batched writes |
bulkCreate() |
500 | ~800ms | Single batch |
bulkCreate() |
1000 | ~1.6s | Split into 2 batches |
bulkWrite() |
large mixed set | — | Parallel BulkWriter; still 1 write/op (no production timing claimed here) |
getById() |
1 | ~30ms | Single document read |
query().get() |
100 | ~100ms | Includes network + deserialization |
query().count() |
10,000 | ~200ms | Aggregation query |
query().aggregate() |
filtered set | ~200ms | Multi-aggregation (one round trip) |
update() |
1 | ~50ms | Partial update |
bulkUpdate() |
100 | ~350ms | Batched updates |
transaction |
2 reads + 2 writes | ~100ms | Atomic operation |
Notes:
- Network latency varies by region
- The Admin SDK has no local document cache (that is a client-SDK feature); what persists between calls is the gRPC channel, not document data
- Use
limit()and pagination for large collections