Skip to content

Performance

How FlintFire operations map to Firestore billing, what each call costs under the hood, cost-optimization patterns, and rough latency benchmarks.

Firestore charges for:

  1. Document reads - Every document returned from a query
  2. Document writes - Every create, update, or delete
  3. Document deletes - Separate charge from writes
  4. Storage - Data stored in your database
  5. Network egress - Data transferred out of Google Cloud
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

Simple Query

const users = await userRepo.query().where('status', '==', 'active').limit(10).get();
  1. Firestore executes the query with the status filter and limit(10)
  2. Returns up to 10 documents
  3. 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();
  1. Firestore normalizes the composite filter into disjunctive form — one disjunct per OR branch
  2. 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)
  3. 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);
  1. Requires at least one orderBy() clause for stable paging
  2. If cursor provided, decodes cursor and fetches that document first (1 read)
  3. Executes query with limit(pageSize + 1) to detect whether more pages exist
  4. Returns up to pageSize items plus hasMore and nextCursor
  5. 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
  1. Validates all 500 documents against schema
  2. Splits into batches of 500 operations (Firestore limit)
  3. Commits each batch sequentially
  4. Cost: 500 writes

Bulk Write (bulkWrite)

await userRepo.bulkWrite(ops); // e.g. 600 mixed create/update/delete
  1. Validates each operation independently; failures become per-item results
  2. Enqueues through one Admin SDK BulkWriter, then closes it
  3. 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
  1. Executes query to find matching documents (150 reads)
  2. Batches updates in groups of 500
  3. Commits all updates
  4. Cost: 150 reads + 150 writes

Delete

await userRepo.delete(userId);
  1. Fetches document to verify existence (1 read)
  2. Deletes the document (1 delete)
  3. 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 });
});
  1. Reads both documents within transaction (2 reads)
  2. Locks both documents until transaction completes
  3. Commits both updates atomically (2 writes)
  4. Cost: 2 reads + 2 writes
  1. Prefer getMany(ids) over N × getById or whereId('in', …) for id lookups

    // ✅ One BatchGetDocuments RPC — input order, null marks missing, no 30-value cap
    const rows = await userRepo.getMany(ids);
    // ❌ N round trips
    const rows = await Promise.all(ids.map(id => userRepo.getById(id)));
    // ❌ whereId('in') caps at 30, returns document-name order, silently drops missing ids
    const 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.

  2. Use count() / aggregate() instead of fetching when you only need aggregates

    // ✅ Efficient — one aggregation request
    const total = await userRepo.query().where('status', '==', 'active').count();
    // ✅ Efficient — count + sum + average in ONE request
    const stats = await orderRepo
    .query()
    .where('status', '==', 'completed')
    .aggregate({
    orders: { kind: 'count' },
    revenue: { kind: 'sum', field: 'total' },
    avgOrder: { kind: 'average', field: 'total' },
    });
    // ❌ Expensive
    const users = await userRepo.query().where('status', '==', 'active').get();
    const total = users.length;
  3. Limit query results

    // Always add reasonable limits
    await userRepo.query().limit(100).get();
  4. Use exists() for presence checks

    // ✅ Reads at most 1 document
    const hasOrders = await orderRepo.query().where('userId', '==', userId).exists();
    // ❌ Reads all matching documents
    const orders = await orderRepo.query().where('userId', '==', userId).get();
    const hasOrders = orders.length > 0;
  5. Select specific fields to reduce bandwidth

    // Reduces network transfer (still charges for full document read)
    const emails = await userRepo.query().select('email').get();
  6. Be cautious with real-time listeners

    // Charges for every document on initial load + every change
    // Use narrow filters
    await orderRepo
    .query()
    .where('userId', '==', userId)
    .where('status', '==', 'active')
    .onSnapshot(callback);

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