Performance
How firestore-orm 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 |
query().limit(100).get() |
100 reads | Reads up to 100 documents |
query().get() |
1 read per result | Charges for every matched document |
query().count() |
1 read per 1000 docs | Aggregation query (cheaper than fetching) |
create() |
1 write | Single write operation |
bulkCreate(100) |
100 writes | Batched but still counts as 100 writes |
update() |
1 write | Even if updating one field |
delete() |
1 delete | Permanently removes document |
query().update() |
1 write per match | Efficient batch update |
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)
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
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.getForUpdateInTransaction(tx, 'acc-1'); const to = await repo.getForUpdateInTransaction(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”-
Use
count()instead of fetching when you only need quantity// ✅ Efficientconst total = await userRepo.query().where('status', '==', 'active').count();// ❌ 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 |
getById() |
1 | ~30ms | Cached locally after first read |
query().get() |
100 | ~100ms | Includes network + deserialization |
query().count() |
10,000 | ~200ms | Aggregation query |
update() |
1 | ~50ms | Partial update |
bulkUpdate() |
100 | ~350ms | Batched updates |
transaction |
2 reads + 2 writes | ~100ms | Atomic operation |
Notes:
- Network latency varies by region
- Firestore has built-in caching for frequently accessed docs
- Use
limit()and pagination for large collections