Firestore Interview Questions and Answers

Last updated:

Check out 35 of the most common Firestore interview questions, then take an AI-powered practice interview

FirebaseNoSQLReal-timeServerlessGoogle Cloud
35+
Questions
14
Basic
14
Intermediate
7
Advanced
Q1

How does the Firestore document and collection model differ from tables and rows, and what does the 1 MiB document limit force you to change?

BasicData Model

Answer

Firestore stores data as documents (JSON-like maps with typed fields) grouped into collections. A collection has no schema, so two documents in the same collection can have completely different fields, and Firestore will not complain. Documents can contain nested maps and arrays, and can also own subcollections, which are collections nested under a document path such as /chats/{chatId}/messages/{messageId}.

A subcollection is not part of its parent document: reading the parent does not read the subcollection, and deleting the parent does not delete the subcollection, which surprises almost every newcomer and leaves orphaned data behind in production. Nesting can go up to 100 levels deep, and the full document path is capped at about 6 KiB. The hard constraint that drives modelling is the 1 MiB maximum document size.

That sounds generous until you try the obvious relational translation: putting all of a chat's messages in an array field on the chat document, or all of an order's line items on the order. Those grow unbounded, and once the document approaches 1 MiB every write rewrites the whole thing, every read of one message downloads all of them, and eventually writes start failing outright. The Firestore-native answer is to promote the unbounded side to a subcollection or a top-level collection and keep only a small summary (last message text, item count, total) on the parent. Interviewers ask this to see whether you model for read patterns and write amplification, not for normal forms.

// Wrong: unbounded array on one document, rewritten on every message
// /chats/{chatId} -> { messages: [ ...thousands of objects... ] }

// Right: subcollection plus a small denormalised summary
import { doc, collection, addDoc, updateDoc, serverTimestamp } from 'firebase/firestore';

const chatRef = doc(db, 'chats', chatId);

await addDoc(collection(chatRef, 'messages'), {
  senderId: uid,
  text,
  createdAt: serverTimestamp(),
});

// Summary the chat-list screen reads without touching the subcollection
await updateDoc(chatRef, {
  lastMessageText: text.slice(0, 120),
  lastMessageAt: serverTimestamp(),
});

Key Points

  • Documents are schemaless maps; collections impose no structure
  • Subcollections are separate from the parent document for reads, writes and deletes
  • 1 MiB maximum document size, roughly 6 KiB maximum path length, 100 levels of nesting
  • Any unbounded list belongs in a subcollection, not an array field
  • Keep a denormalised summary on the parent for list screens
💡 Pro Tip: If you cannot state an upper bound for how many items a field will hold, it must not be an array field on a document.
Q2

What is the difference between Firestore in Native mode and Datastore mode, and can you switch later?

BasicFundamentals

Answer

Both modes run on the same underlying storage engine, but they expose different APIs and different capabilities. Native mode is what people mean by Firestore: it supports the mobile and web client SDKs, real-time snapshot listeners, offline persistence, and Firebase Security Rules, so untrusted clients can talk to the database directly. Its documented soft ceiling is around 10,000 writes per second per database and one write per second per document.

Datastore mode is the App Engine Datastore API in a Firestore body: server-only access, no listeners, no client SDKs, no security rules, but it scales writes far beyond the Native mode ceiling and is the right pick for high-volume server workloads that never need a client connection. The practical rule interviewers want to hear: choose Native mode if any client device will read or write the database or you need live updates, choose Datastore mode for a purely backend, very write-heavy system. You pick the mode when the database is created, and you cannot flip an existing database with data in it between modes.

Since Firestore added support for multiple databases per project, the escape hatch is to create a second database in the other mode and migrate, rather than being stuck. Recent Firestore releases also added a MongoDB-compatible database type for teams that want to keep existing Mongo drivers, which is worth knowing exists but is rarely the subject of an interview question in India yet.

Key Points

  • Native mode: client SDKs, listeners, offline cache, security rules
  • Datastore mode: server-only, no listeners or rules, much higher write ceiling
  • Native mode soft limits: about 10,000 writes/sec per database, 1 write/sec per document
  • Mode is fixed at database creation; migrate by creating a second database
Q3

Explain setDoc, setDoc with merge, updateDoc and addDoc, and when each one silently does the wrong thing.

BasicWrites

Answer

setDoc(ref, data) writes the document at a path you choose and completely replaces it: any field present in the stored document but absent from your payload is deleted. That is the classic production bug, a profile screen that saves only { displayName } and wipes phoneNumber, photoUrl and createdAt. setDoc(ref, data, { merge: true }) does a deep merge instead, creating the document if it does not exist and leaving untouched fields alone, which is what most people actually want. You can also pass { mergeFields: ['a', 'b.c'] } to merge only specific paths. updateDoc(ref, data) patches an existing document and fails with a not-found error if the document does not exist, so it doubles as an existence assertion.

It also accepts dotted field paths such as 'address.city' to update a single key inside a nested map without rewriting the map. addDoc(collectionRef, data) creates a document with a Firestore-generated random ID and returns the reference. In the Admin SDK the equivalents are ref.set, ref.set(data, { merge: true }), ref.update and collection.add, plus ref.create which fails if the document already exists, a useful idempotency primitive that has no direct client SDK equivalent. Note that merge and update both treat arrays as scalar values: passing an array replaces it wholesale, which is why arrayUnion and arrayRemove exist. Interviewers commonly ask what happens when updateDoc targets a missing document, and whether merge deletes nested keys, the answer is that merge never deletes, so removing a field requires deleteField().

import { doc, setDoc, updateDoc, addDoc, collection, deleteField } from 'firebase/firestore';

const ref = doc(db, 'users', uid);

// Destructive: everything not in this object is removed
await setDoc(ref, { displayName: 'Aarav' });

// Safe partial write, creates the doc if missing
await setDoc(ref, { displayName: 'Aarav' }, { merge: true });

// Patch a nested key without rewriting the map; throws if doc is missing
await updateDoc(ref, { 'address.city': 'Pune', updatedAt: Date.now() });

// Remove a field explicitly (merge alone will never delete)
await updateDoc(ref, { legacyToken: deleteField() });

// Auto-generated ID
const created = await addDoc(collection(db, 'orders'), { uid, total: 499 });
console.log(created.id);
💡 Pro Tip: Make merge: true the default in code review. A bare setDoc on an existing document should require a comment explaining why the overwrite is intentional.
Q4

How do you read a document and run a query in the modular Firebase JS SDK, and how is it different from the old namespaced API?

BasicSDK

Answer

Firebase JS SDK v9 replaced the namespaced, chainable API (firebase.firestore().collection('x').where(...).get()) with a modular, functional one, and every release since keeps it. You now import free functions such as collection, doc, query, where, orderBy, limit, getDoc and getDocs, and compose them: getDocs(query(collection(db, 'orders'), where('uid', '==', uid), orderBy('createdAt', 'desc'), limit(20))). The reason for the change is bundle size.

The modular API is tree-shakeable, so a web app that never uses transactions or offline persistence does not ship that code, which typically cuts tens of kilobytes off the Firestore chunk, a real win on the mid-range Android devices that dominate Indian traffic. Reading a single document gives you a DocumentSnapshot: call snap.exists() before snap.data(), because data() returns undefined for a missing document and the resulting TypeError is one of the most common crashes in Firebase apps. A query returns a QuerySnapshot with .docs, .size, .empty and a forEach helper; each entry carries .id separately from .data(), since the document ID is not a field inside the document.

Two gotchas interviewers like: getDocs on a query with no matches resolves successfully with an empty snapshot rather than throwing, and by default a get may be served from the local cache when the device is offline, so check snapshot.metadata.fromCache if freshness matters. The compat layer (firebase/compat/firestore) exists only to ease migration and should not appear in new code.

import { getFirestore, collection, doc, getDoc, getDocs, query, where, orderBy, limit } from 'firebase/firestore';

const db = getFirestore();

// Single document
const snap = await getDoc(doc(db, 'users', uid));
if (!snap.exists()) throw new Error('user not found');
const user = { id: snap.id, ...snap.data() };

// Query
const q = query(
  collection(db, 'orders'),
  where('uid', '==', uid),
  where('status', '==', 'PAID'),
  orderBy('createdAt', 'desc'),
  limit(20),
);
const results = await getDocs(q);
console.log(results.size, results.metadata.fromCache);
const orders = results.docs.map((d) => ({ id: d.id, ...d.data() }));
Q5

What does onSnapshot give you that getDocs does not, and what are the hidden costs of a listener?

BasicRealtime

Answer

onSnapshot registers a live listener. Firestore delivers the current result set immediately, then pushes an updated snapshot every time a document entering, leaving or changing within the query result changes. Each snapshot exposes docChanges() with type 'added', 'modified' or 'removed' plus the old and new index, which is exactly what a RecyclerView or a virtualised list needs for animated diffs instead of a full re-render.

Two properties matter in practice. snapshot.metadata.hasPendingWrites is true while a local write has not yet been acknowledged by the server, which is how latency compensation surfaces: your own write appears in the UI instantly, then the same listener fires again with hasPendingWrites false once the server confirms. snapshot.metadata.fromCache tells you the data came from the local cache, typically while offline. The costs are what interviews probe. The initial snapshot bills one document read per document delivered, and after that you are billed one read per changed document, so a listener on a busy collection is cheap only if churn is low.

If a listener is detached and re-attached, for example because a React component remounts or the app returns from background after a long gap, Firestore may resend the entire result set and bill you for all of it again. Unsubscribing is mandatory, onSnapshot returns an unsubscribe function; forgetting to call it in a useEffect cleanup or in onDestroy leaks listeners and money. A single client is also capped at around 100 concurrent snapshot listeners.

import { onSnapshot, query, collection, where, orderBy, limit } from 'firebase/firestore';

const q = query(
  collection(db, 'chats', chatId, 'messages'),
  orderBy('createdAt', 'desc'),
  limit(50),
);

const unsubscribe = onSnapshot(
  q,
  { includeMetadataChanges: true },
  (snap) => {
    snap.docChanges().forEach((change) => {
      if (change.type === 'added') addRow(change.doc);
      if (change.type === 'modified') patchRow(change.doc);
      if (change.type === 'removed') removeRow(change.doc.id);
    });
    setPending(snap.metadata.hasPendingWrites);
  },
  (err) => console.error('listener failed', err.code),
);

// React: return unsubscribe from useEffect. Never let this leak.
💡 Pro Tip: Always bound a listener with limit(). An unbounded onSnapshot on a growing collection is the single most common cause of a surprise Firebase bill.
Q6

Which field types does Firestore support, and why should you use serverTimestamp() instead of new Date()?

BasicData Model

Answer

Firestore fields can be string, number (stored as 64-bit integer or double), boolean, null, timestamp, geopoint, bytes, reference (a pointer to another document path), array, and map. Arrays cannot contain other arrays directly, though they can contain maps that contain arrays. Sort order across mixed types is defined and fixed: null, then booleans, numbers, timestamps, strings, bytes, references, geopoints, arrays, maps.

That matters because a field that holds a number in some documents and a string in others will sort in two separate blocks, which produces query results that look randomly ordered until you notice the type mismatch. Timestamps are the field type to think hardest about. If a client writes new Date(), the value comes from the device clock, and on real Indian Android fleets a meaningful fraction of devices have clocks minutes or hours off, which corrupts any ordering, TTL or rate-limit logic built on it. serverTimestamp() writes a sentinel that the Firestore backend replaces with its own clock at commit time.

The trade-off is that a local listener sees null for that field until the server acknowledges the write, so your UI code must handle a momentarily null createdAt (or read the pending estimate via snapshot.get('createdAt', { serverTimestamps: 'estimate' })). Security rules can enforce honesty here: require request.resource.data.createdAt == request.time so a client cannot backdate a document. Also remember that the Firestore Timestamp class is not a JS Date, call .toDate() before formatting, and configure Java or Kotlin models accordingly.

import { addDoc, collection, serverTimestamp, Timestamp, GeoPoint, doc } from 'firebase/firestore';

await addDoc(collection(db, 'events'), {
  title: 'Standup',
  attendees: 4,                                  // number
  isPublic: false,                               // boolean
  createdAt: serverTimestamp(),                  // resolved by the server
  startsAt: Timestamp.fromDate(new Date('2026-09-01T09:30:00+05:30')),
  venue: new GeoPoint(19.076, 72.8777),          // Mumbai
  ownerRef: doc(db, 'users', uid),               // document reference
  tags: ['internal', 'weekly'],                  // array (no nested arrays)
});

// Reading back
const t = snap.get('startsAt');
console.log(t.toDate().toISOString(), t.toMillis());
Q7

When should you use auto-generated document IDs versus your own IDs, and what is the hotspot risk with sequential IDs?

BasicData Model

Answer

Firestore's auto IDs (from addDoc or doc(collectionRef) with no path segment) are 20-character random strings, deliberately drawn from a well-distributed keyspace. That randomness is not cosmetic. Firestore stores documents in a single lexicographically ordered key space split into ranges served by different tablets, so keys that arrive in ascending order all land in the same range and hammer one server.

Using a counter, a timestamp prefix, or anything else monotonically increasing as a document ID creates exactly that hotspot and caps sustained throughput in that collection at roughly 500 writes per second no matter how much traffic you send. The same applies to any indexed field with sequential values, not just IDs. Custom IDs are the right choice when the ID is a natural key you will look up directly: store a user profile at /users/{firebaseAuthUid} so you can read it with a single doc get instead of a query, or store an idempotency record at /webhookEvents/{providerEventId} so a duplicate delivery from Razorpay or Stripe fails a create() rather than inserting a second row.

Natural keys also let security rules do things like request.auth.uid == userId directly on the path, without reading the document. If you need a sortable custom ID, prefix it with a hash or reverse the timestamp so the leading characters stay well distributed. A related trick worth mentioning: doc(collection(db, 'orders')) generates a client-side ID without a network round trip, which lets you write parent and child documents in one batch.

import { doc, collection, setDoc, writeBatch } from 'firebase/firestore';

// Natural key: profile lives at a path derived from the auth uid
await setDoc(doc(db, 'users', auth.currentUser.uid), { plan: 'free' }, { merge: true });

// Natural key as an idempotency guard (Admin SDK: create() throws ALREADY_EXISTS)
// await db.doc(`webhookEvents/${providerEventId}`).create({ receivedAt: FieldValue.serverTimestamp() });

// Client-side ID so parent and child can be written together
const orderRef = doc(collection(db, 'orders'));
const batch = writeBatch(db);
batch.set(orderRef, { uid, total: 1299, itemCount: 2 });
batch.set(doc(collection(orderRef, 'items')), { sku: 'TSHIRT-M', qty: 2 });
await batch.commit();

Key Points

  • Auto IDs are random by design to spread writes across key ranges
  • Monotonic IDs or indexed sequential fields cap a collection near 500 writes/sec
  • Use natural keys when you look up by that key or enforce it in rules
  • doc(collectionRef) mints an ID client-side with no round trip
Q8

How is Firestore billed, and which everyday coding patterns turn into a surprise bill?

BasicCost

Answer

Firestore charges per operation, not per byte of compute: document reads, document writes, document deletes, plus stored bytes and network egress. A query that returns 500 documents costs 500 reads, not one. The no-cost tier gives roughly 50,000 reads, 20,000 writes, 20,000 deletes and 1 GiB of storage per day, which is why a hobby app costs nothing and a production app can jump to a real bill overnight.

Regional locations such as asia-south1 (Mumbai) and asia-south2 (Delhi) are priced lower per operation than the nam5 and eur3 multi-regions, so an India-first app should be in an Indian region for both latency and cost, and you should confirm current rates on the price sheet rather than trusting a number in a blog post. The patterns that generate surprise bills are consistent across teams. Unbounded snapshot listeners, where the first attach downloads and bills the whole collection.

Listeners that get torn down and re-attached on every navigation, re-billing the full result set each time. Client-side filtering, where you fetch a whole collection and filter in JavaScript instead of pushing the predicate into where(). Using offset() in the Admin SDK for pagination, which bills every skipped document.

Fan-out Cloud Functions that write to hundreds of documents per event. And security rules that call get() on other documents, because each of those is a billed read on every request. Budget alerts in Google Cloud Billing plus a Cloud Monitoring alert on document read count are the standard guardrails; interviewers in cost-conscious Indian startups ask specifically whether you have set them up.

Key Points

  • Billing is per document read, write and delete, plus storage and egress
  • No-cost tier is about 50K reads, 20K writes, 20K deletes and 1 GiB per day
  • Regional locations (asia-south1, asia-south2) cost less per op than multi-regions
  • Top cost leaks: unbounded listeners, listener churn, client-side filtering, offset pagination, rules get()
  • Set a billing budget and a Cloud Monitoring alert on read count on day one
💡 Pro Tip: Before shipping any screen, ask how many document reads it costs on first paint and on every refresh. If you cannot answer, the screen is not ready.
Q9

What are Firebase Security Rules, and what does the phrase 'rules are not filters' mean in practice?

BasicSecurity Rules

Answer

Security Rules are a declarative language evaluated on Google's servers for every request from a client SDK. They are the authorisation layer for direct client access, and they replace the API server you would otherwise write. A rules file declares match blocks over document paths and grants allow read, allow write (or the finer allow get, list, create, update, delete) when a condition holds.

Inside a condition you can reference request.auth (the decoded Firebase Auth token including custom claims), request.resource.data (the document as it would be after the write), resource.data (the document as it exists now), request.time, and the wildcard path variables. Rules never apply to the Admin SDK, which runs with full privileges, so anything your Cloud Functions or Node backend does bypasses them entirely. The critical concept, and a question that trips up most candidates, is that rules are not filters.

If a rule allows reading a document only when resource.data.uid == request.auth.uid, that does not mean a query over the whole collection returns only your documents. It means the query is rejected outright with permission-denied, because Firestore evaluates a list operation against the query itself and requires that the query could not possibly return a disallowed document. The fix is to write the constraint into the query as well: where('uid', '==', uid).

In other words, rules verify that your query is already scoped correctly, they do not scope it for you. A second consequence is that you cannot express 'return only the fields this user may see', rules are per-document, not per-field, so field-level privacy needs a separate document or subcollection.

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {

    match /users/{userId} {
      allow read: if request.auth != null && request.auth.uid == userId;
      allow update: if request.auth.uid == userId
                    && request.resource.data.plan == resource.data.plan; // client cannot self-upgrade
    }

    match /orders/{orderId} {
      allow get, list: if request.auth != null
                       && resource.data.uid == request.auth.uid;
      allow create: if request.auth.uid == request.resource.data.uid
                    && request.resource.data.createdAt == request.time;
      allow update, delete: if false; // writes only via Cloud Functions
    }
  }
}

// Client MUST also scope the query or the whole list is denied:
// query(collection(db, 'orders'), where('uid', '==', uid))
💡 Pro Tip: Deny by default. Start the rules file with everything closed and open individual paths, never the reverse.
Q10

How do you run Firestore locally, and what does the emulator do differently from production?

BasicTooling

Answer

The Firebase Local Emulator Suite runs Firestore, Auth, Cloud Functions, Storage and Pub/Sub on your machine. Install the Firebase CLI, run firebase init emulators, then firebase emulators:start --only firestore,auth,functions. The Firestore emulator listens on port 8080 by default and ships a UI on 4000 where you can browse documents and, importantly, watch the Requests tab, which shows each rules evaluation and the exact line that allowed or denied it.

That view is the fastest way to debug a permission-denied error. Client SDKs connect with connectFirestoreEmulator(db, '127.0.0.1', 8080), which must be called before any other Firestore operation. The Admin SDK and Cloud Functions pick it up from the FIRESTORE_EMULATOR_HOST environment variable, which also means an unset variable in CI can point your test suite at the real production database, an accident worth guarding against explicitly.

Use --import and --export-on-exit to persist a seeded dataset between runs so tests start from a known state. Differences from production matter in interviews. The emulator enforces security rules and most validation, but it does not enforce real quotas, throughput limits or hotspotting behaviour, so a write pattern that works locally can still throttle in production.

Index requirements are also relaxed by default: the emulator happily runs queries that would need a composite index in production, so a query can pass every local test and then fail with failed-precondition after deploy. Catch that by running a smoke suite against a real staging project before release.

# firebase.json
{
  "firestore": { "rules": "firestore.rules", "indexes": "firestore.indexes.json" },
  "emulators": {
    "firestore": { "port": 8080 },
    "auth": { "port": 9099 },
    "ui": { "enabled": true, "port": 4000 }
  }
}

# Start with a seeded dataset and keep changes
firebase emulators:start --only firestore,auth --import=./seed --export-on-exit

# Client wiring (must run before any read or write)
# import { connectFirestoreEmulator } from 'firebase/firestore';
# if (import.meta.env.DEV) connectFirestoreEmulator(db, '127.0.0.1', 8080);

# Admin SDK / Cloud Functions
export FIRESTORE_EMULATOR_HOST=127.0.0.1:8080
💡 Pro Tip: The emulator does not require composite indexes that production requires. Always run at least one query smoke test against a real staging project.
Q11

What is a collection group query, when do you need one, and what does it require?

BasicQueries

Answer

An ordinary Firestore query runs against one specific collection at one specific path, for example /chats/abc/messages. A collection group query runs against every collection that shares the same final path segment, anywhere in the database, so collectionGroup(db, 'messages') matches messages under every chat. This is how you answer questions that cut across parents: all reviews written by one user across every product, every unpaid invoice across every customer subcollection, every message containing a flag across all conversations.

Two requirements catch people out. First, a collection group query almost always needs a composite index with the query scope set to COLLECTION_GROUP rather than COLLECTION, and single-field collection group indexes are not created automatically the way ordinary single-field indexes are, you have to define them in firestore.indexes.json or click the link in the error. Second, security rules need a dedicated match block using a recursive wildcard, because a rule written at /chats/{chatId}/messages/{messageId} does not apply to a collection group query.

You write match /{path=**}/messages/{messageId} instead, and you must be careful, because that pattern now matches messages subcollections anywhere in your database, including ones you did not intend. A practical modelling note: if nearly all of your reads are collection group queries, the subcollection was probably the wrong choice and a single top-level collection with a parentId field would be simpler, cheaper to index, and easier to secure.

import { collectionGroup, query, where, orderBy, getDocs } from 'firebase/firestore';

const q = query(
  collectionGroup(db, 'messages'),
  where('senderId', '==', uid),
  orderBy('createdAt', 'desc'),
);
const snap = await getDocs(q);
snap.docs.forEach((d) => console.log(d.ref.path)); // chats/<id>/messages/<id>
💡 Pro Tip: Read doc.ref.parent.parent.id in a collection group result to recover which parent document the row belongs to.
Q12

Which query operators does Firestore support, and which common SQL capabilities are simply missing?

BasicQueries

Answer

Firestore supports ==, !=, <, <=, >, >=, in, not-in, array-contains, array-contains-any, plus or() and and() composite filters, ordered with orderBy, bounded with limit and limitToLast, and paged with the cursor functions startAt, startAfter, endAt and endBefore. in, not-in and array-contains-any accept up to 30 values, raised from the original 10, and a query with or() is capped at roughly 30 disjunctions after Firestore normalises it into disjunctive normal form. You may use at most one array-contains and one array-contains-any per query. What is missing is more important in an interview.

There are no joins, so anything relational must be denormalised or fetched with a second round trip. There is no LIKE, no case-insensitive matching and no substring search, only prefix matching faked with a range query (where('name', '>=', term) and where('name', '<=', term + '\uf8ff')), which is case- and accent-sensitive and falls apart on real user input. There is no full-text search at all, that needs Algolia, Typesense, Elasticsearch or the newer vector search.

There is no server-side computed field, no GROUP BY, and no arbitrary aggregation beyond count, sum and average. Ordering has its own trap: any field used in an inequality filter must appear first in orderBy, and if you orderBy a field, documents missing that field are excluded from the result entirely rather than sorted last. That last behaviour silently drops rows and is a favourite interview question.

import { query, collection, where, orderBy, limit, or, and, getDocs } from 'firebase/firestore';

// Prefix search, the only text matching Firestore does natively
const term = 'aar';
const prefix = query(
  collection(db, 'users'),
  where('displayNameLower', '>=', term),
  where('displayNameLower', '<=', term + '\uf8ff'),
  limit(20),
);

// Composite OR filter
const urgent = query(
  collection(db, 'tickets'),
  and(
    where('orgId', '==', orgId),
    or(where('priority', '==', 'P0'), where('slaBreached', '==', true)),
  ),
  orderBy('createdAt', 'desc'),
  limit(50),
);
await getDocs(urgent);

Key Points

  • Operators: ==, !=, <, <=, >, >=, in, not-in, array-contains, array-contains-any, or(), and()
  • in / not-in / array-contains-any accept up to 30 values
  • No joins, no LIKE, no substring or case-insensitive search, no GROUP BY
  • Documents missing an orderBy field are dropped from results, not sorted last
  • Prefix search with a \uf8ff range is the only built-in text trick
Q13

Which indexes does Firestore create automatically, when do you need a composite index, and what is the failed-precondition error telling you?

BasicIndexes

Answer

Firestore indexes every field of every document by default: one ascending and one descending single-field index per scalar field, plus an array-contains index for array fields. That is why a single-field equality or range query just works with no setup, and why Firestore feels index-free right up to the moment it is not. The moment a query needs to combine filters or sorts across more than one field, for example an equality on status plus an orderBy on createdAt, a single-field index can no longer serve it and you need a composite index.

Firestore rejects that query with the gRPC code failed-precondition and a message beginning 'The query requires an index', and the message embeds a console URL that will create precisely the right index in one click. Clicking that link is fine in development and a trap in production, because the index now exists only in the console and the next environment breaks identically. The durable habit is to declare it in firestore.indexes.json and ship it with firebase deploy --only firestore:indexes.

Indexes are not free. Every index entry is stored and billed, and each write must update every index covering that document, so a wide document with many indexed fields costs more to write and more to store. A database is capped at 200 composite indexes, and a single document at 40,000 index entries, which a large array or map field can genuinely exhaust.

When a field is never queried, add a single-field index exemption, which is the standard fix for long text blobs, embedded arrays and large maps. Interviewers usually follow with 'why did it work on my machine', and the answer is that the emulator does not enforce composite index requirements.

// firestore.indexes.json (source of truth, deployed with the CLI)
{
  "indexes": [
    {
      "collectionGroup": "orders",
      "queryScope": "COLLECTION",
      "fields": [
        { "fieldPath": "uid", "order": "ASCENDING" },
        { "fieldPath": "status", "order": "ASCENDING" },
        { "fieldPath": "createdAt", "order": "DESCENDING" }
      ]
    },
    {
      "collectionGroup": "messages",
      "queryScope": "COLLECTION_GROUP",
      "fields": [
        { "fieldPath": "senderId", "order": "ASCENDING" },
        { "fieldPath": "createdAt", "order": "DESCENDING" }
      ]
    }
  ],
  "fieldOverrides": [
    {
      "collectionGroup": "documents",
      "fieldPath": "rawText",
      "indexes": []
    }
  ]
}

# firebase deploy --only firestore:indexes
# Index builds are asynchronous; queries fail until the state is READY.

Key Points

  • Automatic: ascending, descending and array-contains single-field indexes
  • Composite indexes are needed once filters and sorts span multiple fields
  • failed-precondition plus 'The query requires an index' is the signal
  • Commit indexes to firestore.indexes.json; do not click the console link and forget
  • Limits: 200 composite indexes per database, 40,000 index entries per document
  • Exempt never-queried fields to cut write cost and storage
💡 Pro Tip: Deploy indexes before the code that needs them. A backend release that ships a new query ahead of its index gives every user a failed-precondition error until the build finishes.
Q14

Firestore or Realtime Database: what concretely differs, and when would you still choose RTDB in 2026?

BasicFundamentals

Answer

Realtime Database is one giant JSON tree addressed by path; Firestore is collections of documents. That single structural difference drives everything else. In RTDB, reading a node reads the entire subtree beneath it, so deep nesting is a performance bug and the standard advice is to keep the tree flat.

In Firestore, reading a document never reads its subcollections, so nesting is cheap. Querying is the bigger gap: RTDB supports one orderBy child plus a range, with no compound queries, no OR, and no aggregation, whereas Firestore has composite filters, cursors and count, sum and average aggregations. Billing differs in kind, not just in price: RTDB charges for gigabytes downloaded and gigabytes stored, Firestore charges per document operation.

That flips the economics. A presence system writing a tiny boolean thousands of times per minute is nearly free on RTDB and expensive on Firestore, because Firestore counts every one of those as a billed write. Scaling differs too: an RTDB instance is single-region and has a documented ceiling around 200,000 simultaneous connections per database, so growth means sharding across instances, while Firestore scales horizontally without you doing anything.

Security rules also behave differently: RTDB rules cascade, so granting read at a parent path grants everything beneath it and a child rule cannot take it back, while Firestore rules do not cascade unless you write a recursive wildcard. In 2026 the honest reasons to still pick RTDB are presence and ephemeral high-frequency state: onDisconnect() runs server-side when a socket drops, .info/connected gives a reliable connectivity signal, and latency on small deltas is lower. Many production apps in India run both, RTDB for presence and typing indicators, Firestore for durable data.

// The common hybrid: presence in RTDB, durable profile in Firestore
import { getDatabase, ref, onValue, onDisconnect, set, serverTimestamp as rtdbNow } from 'firebase/database';
import { doc, setDoc, serverTimestamp } from 'firebase/firestore';

const rtdb = getDatabase();
const statusRef = ref(rtdb, `status/${uid}`);

onValue(ref(rtdb, '.info/connected'), async (snap) => {
  if (snap.val() === false) return;

  // Registered on the server first, so it still fires if the app is killed
  await onDisconnect(statusRef).set({ state: 'offline', at: rtdbNow() });
  await set(statusRef, { state: 'online', at: rtdbNow() });

  // Only the durable transition is mirrored into Firestore (one billed write)
  await setDoc(doc(db, 'users', uid), { lastSeenAt: serverTimestamp() }, { merge: true });
});

Key Points

  • RTDB is one JSON tree; reads pull the whole subtree
  • Firestore has compound queries, cursors and aggregations; RTDB has one orderBy
  • RTDB bills bandwidth and storage; Firestore bills per document operation
  • RTDB rules cascade downward and cannot be revoked deeper; Firestore rules do not
  • onDisconnect() and .info/connected make RTDB the better presence layer
💡 Pro Tip: If a value changes more than once a second per user and nobody will query it later, it probably belongs in RTDB or Redis, not Firestore.
Q15

How does runTransaction work, why can the callback run several times, and what must never go inside it?

IntermediateTransactions

Answer

A Firestore transaction is a read-then-write unit that is atomic and isolated. You pass a callback that receives a Transaction handle, do all your reads through it, compute, then write. The rule that catches everyone is ordering: every read must happen before every write inside the callback, and the Admin SDK throws if you read after writing.

The concurrency strategy differs by SDK, and interviewers love this detail. Mobile and web client SDKs use optimistic concurrency: they record the version of each document read, attempt the commit, and if any of those documents changed in the meantime the commit fails with ABORTED and the SDK reruns your whole callback, up to five attempts by default. Server SDKs (Node, Java, Python Admin) use pessimistic locking: the transaction takes read locks, so a contended document serialises writers and slow transactions can queue behind each other.

Either way the callback is not guaranteed to run once. That is the source of the second big rule: the callback must be pure with respect to the outside world. No HTTP calls, no Razorpay capture, no FCM push, no incrementing a variable in the enclosing scope, no logging you will later treat as a count.

Do those only after runTransaction resolves, using its return value. Other practical limits: a transaction has a 270 second deadline with a 60 second idle expiry, client SDK transactions can only read documents by reference and cannot run queries (the Admin SDK can), and transactions do not work offline, so a client with no connectivity gets an error rather than a queued write. If your update needs no read, do not use a transaction at all, use increment() or arrayUnion(), which are atomic without contention.

import { runTransaction, doc } from 'firebase/firestore';

const seatsRef = doc(db, 'events', eventId);
const bookingRef = doc(db, 'events', eventId, 'bookings', uid);

const booked = await runTransaction(db, async (tx) => {
  // 1. every read first
  const eventSnap = await tx.get(seatsRef);
  if (!eventSnap.exists()) throw new Error('event-missing');

  const left = eventSnap.data().seatsLeft;
  if (left < 1) throw new Error('sold-out'); // aborts, nothing is written

  // 2. then the writes
  tx.update(seatsRef, { seatsLeft: left - 1 });
  tx.set(bookingRef, { uid, seats: 1, at: Date.now() });

  return left - 1; // returned to the caller after a successful commit
});

// Side effects belong OUT here, never inside the callback
await sendBookingSms(uid, booked);

Key Points

  • All reads before all writes; the Admin SDK enforces it
  • Client SDKs: optimistic, retried up to 5 times on ABORTED
  • Server SDKs: pessimistic read locks, so contention serialises
  • The callback must be side-effect free because it can rerun
  • 270 second deadline, 60 second idle expiry, no offline support
  • No read needed? Use increment()/arrayUnion() instead
💡 Pro Tip: If you catch yourself writing 'await fetch(...)' inside a transaction callback, you have already introduced a duplicate-charge bug.
Q16

When do you reach for writeBatch, when for a transaction, and when for BulkWriter?

IntermediateWrites

Answer

These three solve different problems and candidates routinely conflate the first two. writeBatch groups a set of writes that are applied atomically: all of them land or none do. It performs no reads, so it cannot make a decision based on current data, and because it never reads it never contends and never retries. Use it when you already know exactly what to write, for example creating an order document plus its line items plus a denormalised summary on the user, all in one commit.

A transaction is what you use when the write depends on a read, because only a transaction guarantees the value you read is still current at commit time. The cost is contention and retries, so a transaction on a hot document is a throughput bottleneck while a batch is not. Both are bounded: the historical guidance of 500 operations per batch is still the practical chunk size, and the documented hard cap is the 10 MiB request size, so the safe pattern for bulk work is to chunk at 500 and commit sequentially.

BulkWriter exists in the Admin SDK only and solves a third problem: writing tens or hundreds of thousands of documents as fast as Firestore will accept them, without atomicity. It manages parallelism itself, ramps traffic up gradually to respect the 500/50/5 rule, and retries individual failed writes with exponential backoff through an onWriteError hook where you decide how many attempts a document gets. For migrations, backfills and recursive deletes it is dramatically better than a loop of 500-document batches, which serialises and often trips resource-exhausted. Rule of thumb for the interview: atomic and known ahead of time is a batch, atomic and read-dependent is a transaction, huge and independent is BulkWriter.

// Admin SDK: backfilling a denormalised field across a large collection
import { getFirestore } from 'firebase-admin/firestore';

const db = getFirestore();
const writer = db.bulkWriter();

writer.onWriteError((err) => {
  if (err.failedAttempts < 5) return true;  // retry with backoff
  console.error('giving up', err.documentRef.path, err.code);
  return false;
});

let last = null;
for (;;) {
  let q = db.collection('candidates').orderBy('__name__').limit(1000);
  if (last) q = q.startAfter(last);
  const page = await q.get();
  if (page.empty) break;

  for (const d of page.docs) {
    writer.update(d.ref, { cityLower: (d.get('city') ?? '').toLowerCase() });
  }
  last = page.docs[page.docs.length - 1];
  await writer.flush(); // keep memory bounded
}
await writer.close();

Key Points

  • writeBatch: atomic, no reads, no contention, no retries
  • runTransaction: atomic and read-dependent, retries under contention
  • Chunk bulk work at 500 operations; the hard cap is a 10 MiB request
  • BulkWriter is Admin SDK only, non-atomic, self-throttling with per-write retry
  • BulkWriter respects the 500/50/5 ramp so backfills do not trip resource-exhausted
💡 Pro Tip: A loop of sequential 500-document batches is the slow way to backfill. BulkWriter with flush() every page is usually an order of magnitude faster.
Q17

What do increment(), arrayUnion() and arrayRemove() do that a read-modify-write cannot, and where do they still break?

IntermediateWrites

Answer

These are field transforms, not values. When you send increment(1), the client does not send a number, it sends an instruction that the Firestore backend applies to whatever the field holds at commit time. That removes the read entirely, which has three consequences.

First, correctness: two devices incrementing the same counter concurrently both succeed and the field ends at plus two, whereas a read-modify-write would lose one update unless wrapped in a transaction. Second, latency and cost: no read means no billed read and no round trip before the write. Third, offline support: a transform can be queued in the local mutation queue while the device is offline and applied against the server value later, which a transaction cannot do because a transaction needs to read live data. increment() works on integers and doubles, treats a missing or non-numeric field as zero and overwrites it, and accepts negatives for decrement. arrayUnion() appends only values not already present, comparing by deep equality, so unioning a map with a different field order is treated as a different element and you get a duplicate, which is a real bug in production. arrayRemove() removes every matching instance.

Both leave the array in an order Firestore controls, so never rely on array position. The limits interviewers probe: a transform still counts as one write against the one-write-per-second-per-document soft limit, so a global counter with transforms is just as hot as one without, which is why sharded counters exist. Arrays participate in the 1 MiB document ceiling and in index-entry limits, so an unbounded arrayUnion eventually fails. And you cannot read the resulting value from the write call, you need a listener or a follow-up read.

import { doc, updateDoc, increment, arrayUnion, arrayRemove, serverTimestamp } from 'firebase/firestore';

const postRef = doc(db, 'posts', postId);

// Concurrent likes from many devices, no transaction, no lost updates
await updateDoc(postRef, {
  likeCount: increment(1),
  likedBy: arrayUnion(uid),
  updatedAt: serverTimestamp(),
});

// Unlike
await updateDoc(postRef, {
  likeCount: increment(-1),
  likedBy: arrayRemove(uid),
});

// Trap: these two are NOT the same element to arrayUnion
// arrayUnion({ id: 1, qty: 2 })
// arrayUnion({ qty: 2, id: 1 })  -> in practice compare by a scalar key instead

Key Points

  • Transforms are applied server-side, so no read and no lost updates
  • They queue offline; transactions cannot
  • increment() treats missing or non-numeric fields as 0
  • arrayUnion() dedupes by deep equality; key order in maps changes identity
  • Still one write per document per second, and still bound by 1 MiB
💡 Pro Tip: Never store likedBy as an unbounded array on a hot post. Past a few thousand entries you are rewriting a large document on every like and approaching the index-entry cap.
Q18

A counter needs 3,000 updates per second but Firestore allows about one write per second per document. How do you build it?

IntermediateScaling

Answer

The one-write-per-second-per-document figure is a sustained-throughput guideline, not a hard rejection: short bursts go through, but sustained contention on a single document shows up as rising latency and then ABORTED or resource-exhausted errors. The standard fix is a sharded counter. Instead of one document, you create N shard documents under a subcollection, each holding a partial count.

Writers pick a shard at random and apply increment(1) to it, so the write load is spread across N key ranges and the effective ceiling becomes roughly N writes per second. Readers sum the shards. Sizing is straightforward: divide your peak write rate by the safe per-document rate and add headroom, so 3,000 writes per second wants a few thousand shards, or more realistically a design change.

Reading is the cost you trade away, because summing 1,000 shards costs 1,000 document reads every time. Three mitigations are worth naming in an interview. First, attach a snapshot listener to the shard subcollection so you only pay for changed shards after the initial load.

Second, run a scheduled Cloud Function every minute that rolls the shards up into a single total document, which makes the read path exactly one document at the price of a slightly stale number. Third, if you only need the count occasionally rather than live, skip counters entirely and use the count() aggregation query, which is billed at roughly one read per 1,000 index entries scanned and is far cheaper than either approach. The honest senior answer is often the third one: most 'we need a live counter' requirements survive a one-minute delay, and a rollup document plus count() is simpler than shards. Firebase also ships a distributed-counter extension that implements this pattern for you.

import { doc, collection, getDocs, writeBatch, updateDoc, increment, onSnapshot } from 'firebase/firestore';

const SHARDS = 20;
const shardsRef = collection(db, 'posts', postId, 'viewShards');

// One-time provisioning
export async function initCounter() {
  const batch = writeBatch(db);
  for (let i = 0; i < SHARDS; i++) batch.set(doc(shardsRef, String(i)), { c: 0 });
  await batch.commit();
}

// Write path: random shard, atomic transform, no contention
export function bumpView() {
  const shard = doc(shardsRef, String(Math.floor(Math.random() * SHARDS)));
  return updateDoc(shard, { c: increment(1) });
}

// Read path: pay once up front, then only for changed shards
export function watchCount(cb) {
  return onSnapshot(shardsRef, (snap) => {
    cb(snap.docs.reduce((t, d) => t + d.data().c, 0));
  });
}

Key Points

  • One document sustains about 1 write/sec; bursts are tolerated, sustained load is not
  • N shards give roughly N writes/sec; readers sum the shards
  • Reading 1,000 shards costs 1,000 reads: mitigate with a listener or a rollup job
  • count() aggregation is often cheaper than any counter
  • Symptoms of contention: rising latency, then ABORTED / resource-exhausted
💡 Pro Tip: Pick the shard count from measured peak writes, not from a blog post. Too many shards makes every read expensive for a counter nobody watches.
Q19

How do you paginate a Firestore query correctly, and why is offset() the wrong answer?

IntermediateQueries

Answer

Firestore paginates with cursors, not offsets. You build an ordered query, take the last DocumentSnapshot of the page you just rendered, and pass it to startAfter() for the next page. Because the cursor is positional in the index, Firestore seeks straight to it, so page 500 costs the same as page 1. offset(n), which exists in the Admin SDK, does the opposite: the server still reads and bills every skipped document, so offset(10000) costs 10,000 reads plus the page you wanted.

That is the single most expensive mistake in Firestore pagination and interviewers ask about it directly. There are two cursor forms. startAfter(docSnapshot) is the safe one, because the snapshot carries values for every orderBy field plus the document key. startAfter(value1, value2) takes raw field values in orderBy order and is what you use for a stateless REST API where the client sends back an opaque token instead of holding a snapshot. The correctness trap with the value form is ties: if you order by createdAt alone and fifty documents share the same timestamp, the cursor lands ambiguously and you skip or repeat rows.

Add a tiebreaker, orderBy('createdAt', 'desc') then orderBy(documentId()), and include both values in the cursor. Two more behaviours worth knowing: limitToLast() requires an orderBy and is how you fetch the tail without reversing the sort, and endBefore()/endAt() let you page backwards. Finally, remember that documents missing the orderBy field never appear in a paginated list at all, so a backfill that leaves createdAt unset on old rows makes them permanently invisible rather than merely last.

import { collection, query, where, orderBy, limit, startAfter, getDocs, documentId } from 'firebase/firestore';

const PAGE = 20;

async function fetchPage(cursor) {
  const parts = [
    collection(db, 'jobs'),
    where('city', '==', 'Bengaluru'),
    orderBy('postedAt', 'desc'),
    orderBy(documentId()),          // tiebreaker: postedAt is not unique
    limit(PAGE),
  ];
  if (cursor) parts.push(startAfter(cursor.postedAt, cursor.id));

  const snap = await getDocs(query(...parts));
  const docs = snap.docs.map((d) => ({ id: d.id, ...d.data() }));
  const lastDoc = snap.docs[snap.docs.length - 1];

  return {
    docs,
    // Opaque token a stateless client can send back
    nextCursor: snap.size < PAGE || !lastDoc
      ? null
      : { postedAt: lastDoc.get('postedAt'), id: lastDoc.id },
  };
}

Key Points

  • startAfter(lastSnapshot) seeks in the index: constant cost per page
  • offset(n) bills every skipped document
  • Always add documentId() as a tiebreaker when the sort field can repeat
  • Value-form cursors enable stateless API pagination tokens
  • Documents missing the orderBy field are excluded entirely
💡 Pro Tip: Fetch limit(PAGE) and infer 'has more' from whether you got a full page. Fetching PAGE + 1 to detect the next page costs an extra read on every single page view.
Q20

What can Firestore aggregation queries do, how are count(), sum() and average() billed, and what can they not do?

IntermediateQueries

Answer

Firestore supports server-side aggregations over any query: getCountFromServer(query) for a count, and getAggregateFromServer(query, spec) for a combination of count(), sum(field) and average(field) in one round trip. The results are computed in the index on the server, so the documents themselves are never transferred, which is the whole point. Billing is the part interviewers care about: an aggregation is charged at one document read for every up to 1,000 index entries the aggregation scans, with a minimum of one read.

Counting 50,000 matching documents therefore costs roughly 50 reads instead of 50,000, which turns 'show the total' from an expensive screen into a nearly free one. Alongside that, network egress is negligible because only the aggregate value comes back. The limitations matter just as much.

Aggregations are not real-time: there is no onSnapshot equivalent, so a live count still needs a counter document. They have a 60 second execution deadline, and a long scan over a huge collection can exceed it, which is your signal to maintain a materialised total instead. There is no GROUP BY, so 'count orders per city' is not one query, it is one aggregation per city or a rollup document maintained by a Cloud Function. sum() and average() ignore documents where the field is missing or non-numeric rather than erroring, so a partial backfill quietly skews the number.

An aggregation also needs the same composite index the underlying query would need, and results reflect a consistent snapshot at execution time rather than a running total. The senior framing: use aggregations for on-demand analytics and admin dashboards, use maintained counters for anything shown live to every user.

import { collection, query, where, getCountFromServer, getAggregateFromServer, count, sum, average } from 'firebase/firestore';

const paid = query(
  collection(db, 'orders'),
  where('status', '==', 'PAID'),
  where('createdAt', '>=', startOfMonth),
);

// Just the count
const c = await getCountFromServer(paid);
console.log(c.data().count);

// Count, revenue and average order value in one round trip
const agg = await getAggregateFromServer(paid, {
  orders: count(),
  revenue: sum('amountPaise'),
  aov: average('amountPaise'),
});
const { orders, revenue, aov } = agg.data();
console.log(orders, revenue / 100, Math.round(aov / 100));

// No group-by: this has to be one aggregation per bucket
// for (const city of cities) await getCountFromServer(query(base, where('city','==',city)));

Key Points

  • getCountFromServer and getAggregateFromServer with count(), sum(), average()
  • Billed at one read per up to 1,000 index entries scanned, minimum one
  • No onSnapshot: aggregations are not real-time
  • No GROUP BY; 60 second execution deadline
  • sum()/average() silently skip missing or non-numeric fields
💡 Pro Tip: Replace every 'fetch all matching docs then use .length' in your codebase with getCountFromServer. It is usually the fastest cost win available.
Q21

Firestore now allows inequality filters on multiple fields. What changed, and how do you use Query Explain to see the cost?

IntermediateQueries

Answer

For most of Firestore's life a query could carry range or inequality filters on exactly one field, so 'price between 500 and 2000 and rating above 4' was impossible and teams worked around it by filtering the second condition in application code, paying for every document they threw away. Firestore lifted that restriction, and you can now put inequalities on several different fields in a single query. The constraints that remain are about ordering and indexes.

Every field used in an inequality must appear in the orderBy clause before any other sort field, and if you do not specify orderBy the SDK adds those fields implicitly, which can produce a sort order you did not intend. You still need a composite index covering the fields, and crucially the order of fields inside that index determines how many index entries Firestore has to scan. Putting the most selective inequality first usually cuts the scan dramatically, and Firestore's own guidance is to place equality fields before inequality fields in the index definition.

This is where Query Explain earns its place. The server client libraries expose explain() on a query: with analyze false you get the plan, including which indexes were chosen, and with analyze true Firestore actually executes the query and returns execution stats such as results returned, bytes returned, read operations charged and total execution duration. That read-operations number is the real answer to 'why is this query expensive', because it exposes the gap between documents scanned and documents returned.

There is no explain() in the web or mobile client SDKs, so you profile with the Admin SDK against a representative dataset. Interviewers ask this to separate people who guess at query cost from people who measure it.

// Node Admin SDK
import { getFirestore, Filter } from 'firebase-admin/firestore';
const db = getFirestore();

const q = db.collection('listings')
  .where('city', '==', 'Pune')          // equality first in the index
  .where('pricePaise', '>=', 50000)
  .where('pricePaise', '<=', 200000)
  .where('rating', '>', 4.0)            // second inequality field
  .orderBy('pricePaise')                // inequality fields must lead orderBy
  .orderBy('rating')
  .orderBy('postedAt', 'desc')
  .limit(20);

const { snapshot, explainMetrics } = await q.explain({ analyze: true });
console.log(explainMetrics.planSummary.indexesUsed);
console.log(explainMetrics.executionStats.resultsReturned);
console.log(explainMetrics.executionStats.readOperations);
console.log(explainMetrics.executionStats.executionDuration);
console.log(snapshot.size);

Key Points

  • Multiple inequality filters on different fields are now supported
  • Inequality fields must lead the orderBy, and are added implicitly if you omit them
  • Composite index field order drives how many entries get scanned
  • explain({ analyze: true }) returns indexes used plus read operations and duration
  • Explain is server-SDK only; profile with the Admin SDK
💡 Pro Tip: If readOperations is far larger than resultsReturned, reorder the composite index so the most selective field comes first.
Q22

How do Cloud Functions v2 Firestore triggers behave, and what makes a trigger handler safe in production?

IntermediateCloud Functions

Answer

Firestore triggers in the v2 API come from firebase-functions/v2/firestore as onDocumentCreated, onDocumentUpdated, onDocumentDeleted and onDocumentWritten, each bound to a document path pattern with wildcards you read back from event.params. For update and write events, event.data.before and event.data.after are DocumentSnapshots, and a deleted document has an after snapshot whose exists is false. Three properties determine whether your handler survives production.

First, delivery is at-least-once, so the same event can arrive twice and your handler must be idempotent. The standard technique is to write a marker document keyed by event.id inside the same transaction as the effect, or to make the effect naturally idempotent, for example set() with a deterministic ID rather than addDoc() plus increment(). Second, ordering is not guaranteed.

Two rapid updates to the same document can invoke your function out of order, so branch on the state in event.data.after rather than assuming you are seeing the transition you expect. Third, a handler that writes back to the path it is triggered on will re-trigger itself. Guard by comparing the fields you care about between before and after and returning early when they are unchanged, or by writing to a different collection.

Deployment details that matter in India: pin region: 'asia-south1' so the function sits next to a Mumbai database instead of paying a cross-continent round trip on every event, and set maxInstances so a traffic spike does not fan out into thousands of concurrent instances hammering Firestore and tripping resource-exhausted. Also remember triggers never fire retroactively, so any denormalised field you add needs a separate backfill script, and Admin SDK writes do fire triggers, which is exactly how accidental loops get built.

import { onDocumentUpdated } from 'firebase-functions/v2/firestore';
import { getFirestore, FieldValue } from 'firebase-admin/firestore';

const db = getFirestore();

export const onOrderPaid = onDocumentUpdated(
  { document: 'orders/{orderId}', region: 'asia-south1', maxInstances: 50 },
  async (event) => {
    const before = event.data.before.data();
    const after = event.data.after.data();

    // Loop guard: only act on the transition we care about
    if (before.status === after.status || after.status !== 'PAID') return;

    const marker = db.doc(`_processed/${event.id}`);

    await db.runTransaction(async (tx) => {
      if ((await tx.get(marker)).exists) return;      // duplicate delivery
      tx.set(marker, { at: FieldValue.serverTimestamp() });
      tx.update(db.doc(`users/${after.uid}`), {
        lifetimeValuePaise: FieldValue.increment(after.amountPaise),
      });
    });
  },
);

Key Points

  • v2 exports: onDocumentCreated/Updated/Deleted/Written, wildcards via event.params
  • At-least-once delivery: handlers must be idempotent, use event.id as the key
  • No ordering guarantee: read state from event.data.after, not from the transition
  • Writing back to the same path re-triggers the function unless guarded
  • Pin region asia-south1 and set maxInstances; triggers never fire on existing data
💡 Pro Tip: Give the _processed marker collection a TTL policy on its timestamp field, otherwise your idempotency ledger grows forever and quietly becomes your largest collection.
Q23

How do you configure Firestore offline persistence in the modular SDK, and what happens to reads and writes while the device is offline?

IntermediateOffline

Answer

On Android and iOS the local cache is on by default. On web it is opt-in, and the modern way to enable it is the localCache option on initializeFirestore, not the old enableIndexedDbPersistence() and enableMultiTabIndexedDbPersistence() functions, which are deprecated. You pass persistentLocalCache({ tabManager: persistentMultipleTabManager() }) to get an IndexedDB-backed cache that several browser tabs can share safely, or memoryLocalCache() for a server-rendered or test environment where you explicitly do not want persistence.

Set cacheSizeBytes if the 40 MB default is wrong for your app; Firestore runs LRU garbage collection when the cache exceeds it. Offline behaviour then splits in two. Reads are served from the cache: getDocs() returns whatever the cache can satisfy and sets snapshot.metadata.fromCache to true, and listeners keep firing locally as cached data changes.

Writes go into a durable mutation queue, apply immediately to the local view so the UI updates instantly (latency compensation), surface as hasPendingWrites on the snapshot, and are replayed in order when connectivity returns. What does not work offline is anything needing the server: transactions fail, getDocsFromServer() fails, and serverTimestamp() resolves to null locally until the write is acknowledged. Two APIs worth naming: waitForPendingWrites() resolves once the queue has drained, which is how you show a reliable 'saved' state, and disableNetwork()/enableNetwork() let you simulate offline mode in tests without touching the device. Since persistent-cache indexes arrived, you should also call enablePersistentCacheIndexAutoCreation() on the index manager, because otherwise a large local cache answers queries with a full scan and offline screens feel slow on the low-end Android hardware common across Indian users.

import { initializeApp } from 'firebase/app';
import {
  initializeFirestore,
  persistentLocalCache,
  persistentMultipleTabManager,
  memoryLocalCache,
  getPersistentCacheIndexManager,
  enablePersistentCacheIndexAutoCreation,
  waitForPendingWrites,
} from 'firebase/firestore';

const app = initializeApp(config);

const db = initializeFirestore(app, {
  localCache:
    typeof window === 'undefined'
      ? memoryLocalCache()
      : persistentLocalCache({
          tabManager: persistentMultipleTabManager(),
          cacheSizeBytes: 100 * 1024 * 1024,
        }),
});

// Stop offline queries from full-scanning the local cache
const idx = getPersistentCacheIndexManager(db);
if (idx) enablePersistentCacheIndexAutoCreation(idx);

// Honest 'all changes saved' indicator
await waitForPendingWrites(db);
setSyncState('synced');

Key Points

  • initializeFirestore(app, { localCache: persistentLocalCache(...) }) is the current API
  • persistentMultipleTabManager() for multi-tab web; memoryLocalCache() for SSR and tests
  • Reads fall back to cache (metadata.fromCache); writes queue and replay in order
  • Transactions, getDocsFromServer and resolved serverTimestamps need the network
  • waitForPendingWrites() for a real 'saved' indicator; disableNetwork() for tests
💡 Pro Tip: Do not call initializeFirestore after getFirestore has already run for the same app; the cache settings are ignored and you get the default in-memory cache with no warning.
Q24

How do you unit test Firebase Security Rules, and how do you prove the rules file is actually covered?

IntermediateTesting

Answer

Rules are code with no type checker and no compiler warnings, so untested rules are the normal way production data leaks. The supported harness is @firebase/rules-unit-testing running against the Firestore emulator. You call initializeTestEnvironment with a projectId, the emulator host and port, and the rules file read straight off disk, so the tests exercise the exact artefact you deploy.

From the environment you mint contexts: authenticatedContext('alice') gives you a Firestore instance carrying a fake token for that uid, and you can pass custom claims as a second argument to test role-based rules such as request.auth.token.admin == true. unauthenticatedContext() covers the signed-out case, which people forget to test and which is where most leaks live. Assertions come from assertSucceeds() and assertFails(); assertFails specifically expects a permission-denied error rather than any rejection, so a typo in a collection name does not accidentally pass. Seeding is the piece candidates miss: your rules deliberately block writing the fixture data you need, so you seed inside testEnv.withSecurityRulesDisabled(), which hands you an admin-privileged context for setup only.

Call testEnv.clearFirestore() between tests so state does not leak across cases. Coverage is the part that separates a token test file from real assurance. The emulator serves an HTML coverage report at /emulator/v1/projects/<projectId>:ruleCoverage.html on the Firestore emulator port, which highlights every expression in the rules file and how often it was evaluated, so unexercised branches are visible. Wire firebase emulators:exec around your test command in CI and the whole thing runs headless on every pull request.

import { readFileSync } from 'node:fs';
import { initializeTestEnvironment, assertFails, assertSucceeds } from '@firebase/rules-unit-testing';
import { doc, getDoc, setDoc } from 'firebase/firestore';

let testEnv;

beforeAll(async () => {
  testEnv = await initializeTestEnvironment({
    projectId: 'demo-rules',
    firestore: { host: '127.0.0.1', port: 8080, rules: readFileSync('firestore.rules', 'utf8') },
  });
});
afterEach(() => testEnv.clearFirestore());
afterAll(() => testEnv.cleanup());

test('a user cannot read another user order', async () => {
  await testEnv.withSecurityRulesDisabled(async (ctx) => {
    await setDoc(doc(ctx.firestore(), 'orders/o1'), { uid: 'alice', total: 499 });
  });

  const bob = testEnv.authenticatedContext('bob').firestore();
  await assertFails(getDoc(doc(bob, 'orders/o1')));

  const alice = testEnv.authenticatedContext('alice').firestore();
  await assertSucceeds(getDoc(doc(alice, 'orders/o1')));
});

Key Points

  • initializeTestEnvironment reads the real firestore.rules file
  • authenticatedContext(uid, claims) and unauthenticatedContext() build test clients
  • assertFails() specifically expects permission-denied, not any error
  • Seed fixtures inside withSecurityRulesDisabled(); clearFirestore() between tests
  • Coverage report at <emulator>/emulator/v1/projects/<id>:ruleCoverage.html
  • Run the suite headless with firebase emulators:exec in CI
💡 Pro Tip: Write the assertFails case before the assertSucceeds case. A rules file that denies everything passes all your happy-path tests and none of your negative ones.
Q25

Your rules call get() on another document to check a role. What does that cost, what are the limits, and what is the alternative?

IntermediateSecurity Rules

Answer

get(/databases/$(database)/documents/orgs/$(orgId)/members/$(request.auth.uid)) inside a rule is a real Firestore lookup, evaluated on every request that hits that rule, and every one of them is a billed document read on top of the read the user actually asked for. On a list query the arithmetic gets worse fast, because rules are evaluated per document in the result set unless the lookup is hoisted. Firestore does cache identical get() calls within a single request evaluation, so calling the same path twice in one rule is not billed twice, but different paths are.

There are hard limits as well: 10 document access calls per single-document request, and 20 for a multi-document request such as a query, a transaction or a batched write. Exceed them and the request fails outright rather than degrading. Push a role check into a nested loop of rules and you will hit that ceiling in production long before you hit it in testing.

The cheaper alternative is to move authorisation data into the auth token as custom claims, set from a trusted backend with admin.auth().setCustomUserClaims(uid, { role: 'recruiter', orgId }). Claims arrive inside request.auth.token, cost nothing to evaluate, and make rules trivially fast. The trade-offs are real and interviewers will press on them: claims are capped at about 1,000 bytes, and they only refresh when the ID token does, which is up to an hour, so a revoked role stays live until the client calls getIdToken(true) or you force a re-auth. The usual production compromise is claims for slow-moving, coarse facts like orgId and role, and a get() for fine-grained, fast-changing permissions, with the membership document denormalised so a single lookup answers the question.

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {

    // Free: comes from the ID token, no document read
    function orgFromClaim() { return request.auth.token.orgId; }
    function isRecruiter() { return request.auth.token.role == 'recruiter'; }

    // Billed: one document read per request, reuse the function so it caches
    function membership(orgId) {
      return get(/databases/$(database)/documents/orgs/$(orgId)/members/$(request.auth.uid)).data;
    }

    match /orgs/{orgId}/jobs/{jobId} {
      allow get, list: if request.auth != null && orgFromClaim() == orgId;
      allow create: if isRecruiter() && orgFromClaim() == orgId;
      allow delete: if membership(orgId).canDelete == true; // costs a read
    }
  }
}

// Backend, on role change:
// await admin.auth().setCustomUserClaims(uid, { role: 'recruiter', orgId });
// Client must then call getIdToken(true) to pick it up before the hour is out.

Key Points

  • Each rules get()/exists() is a billed read, evaluated per request
  • Limits: 10 document access calls for single-document requests, 20 for queries and transactions
  • Identical paths are cached within one evaluation; different paths are not
  • Custom claims are free to evaluate but capped near 1,000 bytes
  • Claims go stale for up to an hour unless you force getIdToken(true)
💡 Pro Tip: Count the rules reads for your busiest list screen. Twenty rows times one get() per row is twenty extra billed reads for a single scroll.
Q26

How do you model a many-to-many relationship, for example candidates applying to jobs, so that both sides query cheaply and rules stay enforceable?

IntermediateData Model

Answer

Firestore has no joins, so many-to-many is a modelling decision, and the right one depends on cardinality and on what security rules need to check. Three patterns come up. An array of IDs on one side (job.applicantIds) works only when the list is small and bounded, because you can query it with array-contains but every append rewrites the document, it counts against the 1 MiB limit, and you can use just one array-contains per query.

A membership map (members: { uid: 'owner' }) is the pattern to reach for when rules need the answer, because a rule can read resource.data.members[request.auth.uid] with no extra document read at all, which is the cheapest authorisation check Firestore offers. It still has the same size ceiling, so it suits teams and workspaces, not applicant lists. For anything unbounded the answer is a join collection: a top-level applications collection where each document carries jobId, candidateId, status and the handful of denormalised fields each list screen needs (job title, candidate name, applied timestamp).

Now both directions are one indexed query, where('jobId','==',id) and where('candidateId','==',uid), the collection scales without limit, rules can check request.auth.uid == resource.data.candidateId directly on the document, and you can carry per-relationship state such as the interview stage, which an array of IDs cannot. The price is denormalisation drift: if the job title changes, the copies inside application documents go stale, so you either accept the snapshot semantics (often correct, since an application should record the title as it was) or fix them with a Cloud Function fan-out. Use a deterministic document ID like `${jobId}_${candidateId}` and duplicate applications become impossible without a transaction.

import { doc, setDoc, query, collection, where, orderBy, limit, getDocs, serverTimestamp } from 'firebase/firestore';

// Join collection with a deterministic id: applying twice is impossible
const appId = `${jobId}_${uid}`;
await setDoc(doc(db, 'applications', appId), {
  jobId, candidateId: uid,
  jobTitle: job.title,            // denormalised for the candidate's list screen
  candidateName: profile.name,    // denormalised for the recruiter's list screen
  stage: 'APPLIED',
  appliedAt: serverTimestamp(),
});

// Recruiter view: applicants for one job
const forJob = query(
  collection(db, 'applications'),
  where('jobId', '==', jobId),
  where('stage', 'in', ['APPLIED', 'SHORTLISTED']),
  orderBy('appliedAt', 'desc'),
  limit(25),
);

// Candidate view: same collection, other direction, second composite index
const forCandidate = query(
  collection(db, 'applications'),
  where('candidateId', '==', uid),
  orderBy('appliedAt', 'desc'),
);
await getDocs(forCandidate);

Key Points

  • Array of IDs: bounded lists only, one array-contains per query, rewrites on append
  • Members map: best when rules must check membership with zero extra reads
  • Join collection: the only pattern that scales, and the only one that holds per-edge state
  • Denormalise just the fields each list screen renders
  • Deterministic composite IDs prevent duplicate edges without a transaction
Q27

Walk through the Firestore error codes you actually see in production and how each one should be handled.

IntermediateError Handling

Answer

Firestore surfaces gRPC status codes on error.code, and treating them all as one generic failure is the difference between a resilient client and a support queue. permission-denied means security rules rejected the operation, and on a list query it usually means the query is not scoped the way the rules require rather than that the user lacks access, so check the query before you touch the rules. failed-precondition almost always means a missing composite index, and the message carries the URL that creates it; it also appears when a transaction precondition fails. unavailable is a transient network or backend problem and is safe to retry, since the SDKs already retry idempotent reads internally with backoff. deadline-exceeded means the operation ran past its deadline; the danger is that the write may still have committed on the server, so retrying is only safe if the write is idempotent, which is another argument for deterministic document IDs. aborted is transaction contention, meaning too many writers on the same documents, and the fix is not more retries but reducing contention through sharding or atomic transforms. resource-exhausted means you hit a quota or, more often, hotspotted a key range with too many writes per second, and the correct response is to back off and slow down rather than hammer harder. already-exists comes from Admin SDK create() and is a feature: it is how you make webhook processing idempotent. not-found comes from updateDoc on a missing document. unauthenticated means the ID token expired or was never attached. cancelled usually means your own code tore down a listener mid-flight. In practice you handle three buckets: retry with jittered backoff for unavailable and aborted, surface a real message for permission-denied and not-found, and alert on failed-precondition and resource-exhausted because both mean something is wrong with the deployment rather than with the user.

import { FirestoreError } from 'firebase/firestore';

const RETRYABLE = new Set(['unavailable', 'aborted', 'internal']);

export async function withRetry(fn, attempts = 4) {
  for (let i = 0; ; i++) {
    try {
      return await fn();
    } catch (e) {
      const code = (e as FirestoreError).code;

      if (code === 'failed-precondition') {
        // Missing index: the message contains the create-index URL
        reportToSentry(e, { fatal: true });
        throw e;
      }
      if (code === 'resource-exhausted') {
        await sleep(5000 * (i + 1)); // slow down, do not hammer
        if (i >= attempts) throw e;
        continue;
      }
      if (!RETRYABLE.has(code) || i >= attempts) throw e;

      await sleep(Math.min(2 ** i * 200, 4000) + Math.random() * 200);
    }
  }
}

Key Points

  • permission-denied on a list usually means an unscoped query, not a rules bug
  • failed-precondition is almost always a missing composite index
  • deadline-exceeded may have committed: retry only idempotent writes
  • aborted is transaction contention; fix the design, not the retry count
  • resource-exhausted means quota or hotspotting: back off, do not retry harder
  • already-exists from create() is the cleanest idempotency primitive available
Q28

Firestore has no full-text search. How do you build search on a Firestore-backed product, and what does each option cost you?

IntermediateSearch

Answer

Firestore matches values, not text. There is no LIKE, no tokenisation, no stemming, no relevance ranking and no case-insensitive comparison, so 'search' has to be built beside it. The cheapest option is a prefix range query on a normalised field: store displayNameLower alongside displayName and query where('displayNameLower','>=',term) and where('displayNameLower','<=',term + '\uf8ff').

This costs nothing extra, stays consistent automatically, and is genuinely fine for autocomplete on a name or an SKU. It only matches from the start of the string, it is accent sensitive, and it cannot rank, so it collapses the moment someone searches for a word in the middle of a job title. The next step up is a keyword array: precompute the tokens you want matchable, lowercase them, store them in a searchTokens array and query with array-contains-any.

That gets you word-level matching and works offline, at the cost of index entries (every token is an indexed entry, and the 40,000 entries per document cap is real) and of a backfill every time you change the tokenisation. Neither approach gives typo tolerance or ranking, which is why any serious product mirrors Firestore into a search engine. The standard path is a Cloud Function on document writes that pushes into Algolia, Typesense or Elasticsearch, or one of the official Firebase Extensions that does exactly this, with a backfill job for existing data.

You then accept eventual consistency between the two stores and the extra bill. Since Firestore added vector search with findNearest, a fourth option exists for semantic matching, which is complementary rather than a replacement: keyword search still wins for exact identifiers and filters.

import { onDocumentWritten } from 'firebase-functions/v2/firestore';

// 1. Cheap in-Firestore token index, written alongside the document
function tokenize(title) {
  return Array.from(
    new Set(
      title.toLowerCase().normalize('NFKD').replace(/[^a-z0-9 ]/g, ' ')
        .split(/\s+/).filter((w) => w.length > 1),
    ),
  ).slice(0, 40); // stay well clear of index-entry limits
}
// await setDoc(jobRef, { title, searchTokens: tokenize(title) }, { merge: true });
// query(collection(db,'jobs'), where('searchTokens','array-contains-any',['react','node']))

// 2. Mirror into a real engine for ranking and typo tolerance
export const syncJobToSearch = onDocumentWritten(
  { document: 'jobs/{jobId}', region: 'asia-south1' },
  async (event) => {
    const after = event.data.after;
    if (!after.exists) return searchIndex.deleteObject(event.params.jobId);
    const { title, city, salaryMaxLpa } = after.data();
    return searchIndex.saveObject({ objectID: event.params.jobId, title, city, salaryMaxLpa });
  },
);

Key Points

  • Prefix range with \uf8ff: free, prefix-only, case and accent sensitive, no ranking
  • searchTokens array plus array-contains-any: word matching, at the cost of index entries
  • Mirror to Algolia, Typesense or Elasticsearch via a write trigger for real search
  • Any mirror needs a backfill for existing documents and accepts eventual consistency
  • findNearest vector search covers semantic matching, not exact identifiers
💡 Pro Tip: Before adding Algolia, check whether the product actually needs ranking. A normalised prefix field plus good filters answers most in-app search boxes for zero extra spend.
Q29

Explain Firestore hotspotting at the storage layer, the 500/50/5 rule, and how a random document ID can still produce a hotspot.

AdvancedScaling

Answer

Firestore stores everything in one lexicographically sorted key space, split into contiguous ranges, and each range is served by a set of servers. Throughput scales because ranges split and redistribute as load grows, but a split takes time and can only help if the traffic is spread across keys. Concentrated writes into a narrow key range are a hotspot: latency climbs, then you get ABORTED and resource-exhausted, regardless of how much total capacity the database has.

This is why 500/50/5 exists. When ramping traffic into a new collection, start at about 500 operations per second and increase by 50 percent every 5 minutes, which gives Firestore time to split ranges ahead of you. Skipping the ramp is the classic cause of a failed launch-day migration.

The subtle part, and the one interviewers use to separate levels, is that random document IDs are necessary but not sufficient. Every index is its own key space. If you write documents with random IDs but each carries createdAt: serverTimestamp(), then the index on createdAt receives strictly increasing keys, so all new index entries land at the end of one range and that index hotspots even though the document keyspace is perfectly spread.

The same applies to an auto-incrementing order number, a monotonic sequence, or a status field where 99 percent of documents share one value. Mitigations follow from the diagnosis: exempt the offending field from indexing when you never query it, prepend a small random shard prefix or a hash of the document ID when you do (and fan out queries across shard values), avoid monotonic custom IDs entirely, and use Key Visualizer in the Google Cloud console, which draws a heat map of key-space access over time and makes a hot range visually obvious.

// Symptom: 200 writes/sec of event docs with random IDs still throw resource-exhausted,
// because every doc carries an ascending createdAt that is indexed.

// Fix A: stop indexing the field you never filter on (firestore.indexes.json)
{
  "fieldOverrides": [
    { "collectionGroup": "events", "fieldPath": "createdAt", "indexes": [] }
  ]
}

// Fix B: you DO need to query it, so shard the index key space
import { addDoc, collection, query, where, orderBy, limit, serverTimestamp } from 'firebase/firestore';

const SHARDS = 10;
await addDoc(collection(db, 'events'), {
  shard: Math.floor(Math.random() * SHARDS),   // spreads the composite index
  createdAt: serverTimestamp(),
  type: 'PROFILE_VIEW',
});

// Reads fan out across shard values and merge client-side
const perShard = Array.from({ length: SHARDS }, (_, s) =>
  query(collection(db, 'events'), where('shard', '==', s), orderBy('createdAt', 'desc'), limit(50)),
);

Key Points

  • One sorted key space split into ranges; concentrated keys defeat horizontal scale
  • 500/50/5: start at 500 ops/sec, add 50 percent every 5 minutes
  • Every index has its own key space: a monotonic timestamp hotspots its index
  • Fixes: index exemptions, hash or shard prefixes, never monotonic document IDs
  • Key Visualizer draws the heat map that confirms which range is hot
💡 Pro Tip: Before a bulk import, warm the collection: write at 500 ops/sec for five minutes, then ramp. Firestore cannot pre-split a range it has never seen traffic on.
Q30

Your Firestore bill jumped from ₹40,000 to ₹6,00,000 in a month with no traffic increase. How do you find the cause?

AdvancedCost

Answer

Work from the aggregate down to the code path. Start in Cloud Monitoring with the Firestore metrics, document read count, write count and delete count, and chart them over the billing period to see exactly when the step change happened and whether it is reads, writes or deletes. A read-driven spike with flat writes almost always means query or listener behaviour changed, not user behaviour.

Correlate that timestamp against your deploy history and the Firebase console Usage tab. From there, the usual suspects in order of frequency: a snapshot listener that lost its limit() or its unsubscribe, so every navigation re-attaches and re-downloads the whole result set; a screen that fetches an entire collection and filters client-side; a new security rule that added a get() call, which multiplies every read on a list screen by the number of rows; a Cloud Functions trigger that writes back into a collection it listens to, creating a feedback loop that shows as writes climbing geometrically; a badly configured retry that hammers on unavailable; or automated abuse hitting an unprotected public collection. Check the billing export in BigQuery to attribute spend by SKU, which separates read charges from storage and egress.

For the abuse case, App Check is the answer, since it attests that requests come from your genuine app builds and blocks scripted clients. Then put the guardrails back: a Cloud Billing budget with alerts at 50, 90 and 100 percent, a Cloud Monitoring alert policy on read count per minute rather than per month so you find out in an hour rather than at invoice time, and a code review rule that every listener declares a limit and an unsubscribe. Interviewers ask this because the failure is common, cheap to prevent and expensive to discover late.

Key Points

  • Chart read, write and delete counts in Cloud Monitoring to time the step change
  • Reads up with writes flat means listener or query behaviour, not user growth
  • Prime suspects: unbounded or leaked listeners, client-side filtering, a new rules get(), trigger loops
  • BigQuery billing export attributes spend by SKU
  • App Check blocks scripted abuse of public collections
  • Fix forward with budget alerts plus a per-minute read-count alert policy
💡 Pro Tip: A monthly budget alert tells you after the money is gone. The alert that matters fires on reads per minute crossing a threshold you chose deliberately.
Q31

Describe a real recovery plan for Firestore: scheduled backups, point-in-time recovery, exports and TTL policies.

AdvancedOperations

Answer

Firestore gives you three distinct mechanisms and they protect against different failures. Scheduled backups are the managed option: you create a daily or weekly schedule with gcloud firestore backups schedules create, Firestore takes backups without consuming read operations, and you restore into a new database with gcloud firestore databases restore. Restore always creates a new database rather than overwriting the live one, so recovery means restore, verify, then repoint your application, and your runbook should say so explicitly because that repoint is where teams lose the extra hour.

Point-in-time recovery is the second mechanism and it is the one that saves you from a bad script. With PITR enabled Firestore retains earlier versions of your data for the past 7 days, letting you read or export the database as it existed at a chosen timestamp, at fine granularity for the most recent hour and per-minute granularity beyond that. That is the difference between recovering from a delete-everything bug and losing a day.

Managed export to Cloud Storage is the third, and it is not really a backup, it is a data-movement tool: it bills a read per document, it is not a consistent snapshot unless you pass a snapshot time backed by PITR, and its main use is loading into BigQuery for analytics or moving between projects. TTL policies belong in the same conversation because unmanaged growth is a slow-motion incident. Set a TTL on a timestamp field with gcloud firestore fields ttls update and Firestore deletes expired documents automatically, usually within 24 hours of expiry. Two things to know: TTL deletes are billed as ordinary deletes, and they do fire onDocumentDeleted triggers, so a TTL on a large collection can quietly invoke millions of function executions.

# Daily managed backups
gcloud firestore backups schedules create \
  --database='(default)' --recurrence=daily --retention=7d

# Point-in-time recovery on the database
gcloud firestore databases update --database='(default)' --enable-pitr

# Read the database as it was before the bad migration ran
gcloud firestore export gs://gs-firestore-dr/2026-08-11-prefix \
  --database='(default)' \
  --snapshot-time='2026-08-11T04:30:00Z'

# Restore a backup: this creates a NEW database, it never overwrites
gcloud firestore backups list --location=asia-south1
gcloud firestore databases restore \
  --source-backup=projects/P/locations/asia-south1/backups/B \
  --destination-database='recovered-2026-08-11'

# Automatic cleanup of an idempotency ledger
gcloud firestore fields ttls update expireAt \
  --collection-group=_processed --enable-ttl

Key Points

  • Scheduled backups do not consume read ops; restore always creates a NEW database
  • PITR retains 7 days of versions and is what recovers a bad script
  • Managed export bills a read per document and is not consistent without a PITR snapshot time
  • TTL policies delete expired docs within roughly 24 hours of the timestamp
  • TTL deletes are billed and DO fire delete triggers
💡 Pro Tip: Rehearse the restore once a quarter into a scratch database. A backup schedule nobody has ever restored from is a compliance checkbox, not a recovery plan.
Q32

How does Firestore vector search work, what does findNearest require, and where does it fall short of a dedicated vector database?

AdvancedVector Search

Answer

Firestore added a native vector type and a findNearest query so you can store embeddings next to the documents they describe instead of running a second datastore. You write the embedding with the Vector field value (FieldValue.vector([...]) in the Admin SDK), create a vector index with gcloud firestore indexes composite create specifying the field path, the dimension and a flat index configuration, and then query with findNearest, passing the query vector, a limit, and a distance measure of COSINE, EUCLIDEAN or DOT_PRODUCT. You can ask Firestore to write the computed distance into a result field with distanceResultField and cut off weak matches with distanceThreshold, which matters because a nearest-neighbour query always returns k results no matter how irrelevant they are.

Pre-filtering is supported: combine equality or range filters with findNearest and Firestore applies the filter before the vector scan, which is the feature that makes it genuinely useful, since 'nearest job description within this city and salary band' is the real query. That also means the composite index must include the filter fields alongside the vector field. The constraints are what a senior candidate should volunteer.

Dimension and result-count limits apply (up to 2,048 dimensions, and a bounded k per query), the index type is flat rather than HNSW or IVF, so recall is exact but latency and cost grow with the filtered candidate set rather than staying near constant, and findNearest is available in the server SDKs rather than being something you fire from an untrusted client. There is no hybrid keyword-plus-vector scoring, no reranking and no built-in embedding generation, so you call Vertex AI or your own model and write the vector yourself. For a few million vectors with strong pre-filters it is excellent and removes an entire service from your architecture. For tens of millions with pure semantic recall, a purpose-built index still wins.

// gcloud firestore indexes composite create \
//   --collection-group=jobs --query-scope=COLLECTION \
//   --field-config=order=ASCENDING,field-path=city \
//   --field-config=vector-config='{"dimension":768,"flat":{}}',field-path=embedding

import { getFirestore, FieldValue } from 'firebase-admin/firestore';
const db = getFirestore();

// Write: embedding produced by Vertex AI, stored beside the document
await db.collection('jobs').doc(jobId).set(
  { title, city: 'Bengaluru', embedding: FieldValue.vector(await embed(title + ' ' + jd)) },
  { merge: true },
);

// Read: pre-filter, then nearest neighbours
const results = await db.collection('jobs')
  .where('city', '==', 'Bengaluru')
  .findNearest({
    vectorField: 'embedding',
    queryVector: FieldValue.vector(await embed(candidateSummary)),
    limit: 10,
    distanceMeasure: 'COSINE',
    distanceResultField: 'score',
    distanceThreshold: 0.35,
  })
  .get();

results.docs.forEach((d) => console.log(d.get('title'), d.get('score')));

Key Points

  • Store embeddings as a Vector field; index with gcloud specifying dimension and flat config
  • findNearest takes a query vector, limit and COSINE / EUCLIDEAN / DOT_PRODUCT
  • distanceResultField and distanceThreshold stop irrelevant nearest neighbours leaking in
  • Pre-filters run before the vector scan; the index must cover the filter fields
  • Flat index, server SDKs only, no hybrid scoring or reranking, embeddings are your job
💡 Pro Tip: Always set distanceThreshold. Without it a semantic search for a nonsense query still returns ten confident-looking results and your product ships hallucinated matches.
Q33

You need to backfill a new field across 20 million documents in a live database. Design the migration.

AdvancedOperations

Answer

Start with arithmetic, because it decides the design: 20 million reads plus 20 million writes is a real invoice, and at a conservative sustained rate the job runs for hours, so it must be resumable. Do not use a single-threaded loop of paged queries; it is serial and it restarts from zero when the process dies. The Admin SDK gives you partitionQuery for exactly this, exposing getPartitions(n) on a collection group query, which returns cursor pairs that carve the collection into roughly equal ranges you can hand to independent workers, Cloud Run jobs or Dataflow tasks.

Each worker owns one partition, reads it with a cursor, writes through a BulkWriter with an onWriteError retry policy, and checkpoints its progress to a control document so a restart resumes rather than repeats. Respect the 500/50/5 ramp; BulkWriter does this for you, hand-rolled parallelism does not, and skipping it produces resource-exhausted within minutes. The trap most candidates miss is triggers.

Twenty million writes into a collection with an onDocumentWritten function is twenty million function invocations, which will exhaust your function quota, generate a second wave of Firestore writes, and cost more than the migration itself. Guard it: have the handler early-return when only the migration field changed, gate it behind a feature flag read from Remote Config or a control document, or temporarily undeploy the trigger. Make the write idempotent and skippable, so re-running the job over already-migrated documents is a no-op you can detect with a schemaVersion field rather than a full rewrite. Two alternatives are worth naming: for a transform you can express in SQL, export to Cloud Storage, load into BigQuery, compute, and import back, since managed imports do not fire Cloud Functions triggers; and for anything you can compute lazily, do a dual-read in application code and migrate on access instead of running a batch job at all.

import { getFirestore } from 'firebase-admin/firestore';
const db = getFirestore();

// Carve the collection into ranges; each worker takes one
export async function planPartitions(count = 64) {
  const parts = [];
  const partitions = db.collectionGroup('candidates').getPartitions(count);
  for await (const p of partitions) {
    parts.push({ start: p.startAt ?? null, end: p.endBefore ?? null });
  }
  return parts; // persist to /migrations/{id}/partitions for resumability
}

export async function runPartition(partitionId, query) {
  const writer = db.bulkWriter();
  writer.onWriteError((e) => e.failedAttempts < 5);

  let last = null, done = 0;
  for (;;) {
    let page = query.limit(500);
    if (last) page = page.startAfter(last);
    const snap = await page.get();
    if (snap.empty) break;

    for (const d of snap.docs) {
      if (d.get('schemaVersion') === 2) continue;      // idempotent re-run
      writer.update(d.ref, { cityLower: (d.get('city') ?? '').toLowerCase(), schemaVersion: 2 });
    }
    last = snap.docs[snap.docs.length - 1];
    done += snap.size;
    await writer.flush();
    await db.doc(`migrations/cityLower/parts/${partitionId}`).set({ cursor: last.id, done });
  }
  await writer.close();
}

Key Points

  • partitionQuery / getPartitions splits the collection for parallel workers
  • BulkWriter handles the 500/50/5 ramp and per-write retries; hand-rolled loops do not
  • Checkpoint each partition cursor so the job is resumable, not restartable
  • Guard or undeploy triggers, otherwise the backfill fans out into millions of invocations
  • Use a schemaVersion field so re-runs are cheap no-ops
  • Managed import does not fire triggers; lazy migrate-on-read avoids the job entirely
💡 Pro Tip: Dry-run the migration against 10,000 documents first and multiply. If the extrapolated cost surprises you, it will surprise your finance team more.
Q34

How would you isolate tenants in a multi-tenant SaaS on Firestore, and when do you give a tenant its own database?

AdvancedArchitecture

Answer

There are three isolation levels and the interview is about picking deliberately. The default is logical isolation in one database: every document carries a tenantId, every query filters on it, and security rules require request.auth.token.tenantId to match. It is the cheapest to operate and the easiest to get subtly wrong, because a single query that forgets the tenant filter is a cross-tenant data leak, and rules will only save you if every collection has a rule that checks it.

Enforce the filter in a shared data-access layer rather than trusting each screen, and write rules tests that assert tenant B is denied on tenant A documents. The middle option is a path-prefixed hierarchy, /tenants/{tenantId}/jobs/{jobId}, which makes the tenant boundary structural: rules match on the path variable with no document read, an accidental unscoped query is impossible because the path itself is scoped, and deleting a tenant is a recursive delete of one subtree. The cost is that cross-tenant reporting now needs collection group queries with recursive wildcard rules, which reintroduces the leak risk in a different place.

The third level is a separate named database per tenant. Firestore supports multiple databases in a project, created with gcloud firestore databases create and addressed by passing the database ID to getFirestore, each with its own security rules, its own indexes and its own quota, deployed by declaring an array of firestore targets in firebase.json. Reserve this for the reasons that logical isolation genuinely cannot solve: a regulatory data-residency requirement that forces one customer into a different region, a contractual demand for physically separate storage, or an enterprise tenant whose write volume would starve everyone else. Against that, weigh real operational drag: schema and index changes multiply by tenant count, per-project database limits apply, and cross-tenant analytics gets much harder.

// firebase.json: one rules and index set per database
{
  "firestore": [
    { "database": "(default)", "rules": "rules/shared.rules", "indexes": "idx/shared.json" },
    { "database": "tenant-acme", "rules": "rules/tenant.rules", "indexes": "idx/tenant.json" }
  ]
}

// gcloud firestore databases create --database=tenant-acme \
//   --location=asia-south1 --type=firestore-native

import { getFirestore } from 'firebase/firestore';
const shared = getFirestore(app);                 // (default)
const acme = getFirestore(app, 'tenant-acme');    // isolated database

// Structural isolation in the shared database: the path IS the boundary
// rules_version = '2';
// match /tenants/{tenantId}/{document=**} {
//   allow read, write: if request.auth.token.tenantId == tenantId;
// }

Key Points

  • Logical: tenantId on every document, enforced in rules and a shared query layer
  • Structural: /tenants/{tenantId}/... makes the boundary a path variable, free to check in rules
  • Physical: a named database per tenant, own rules, own indexes, own quota
  • Named databases via gcloud firestore databases create and getFirestore(app, dbId)
  • Reserve per-tenant databases for data residency, contractual isolation or a starving neighbour
  • Every model needs a rules test that asserts tenant B is denied on tenant A data
💡 Pro Tip: Whichever model you pick, add one CI test that signs in as tenant B and asserts permission-denied on a tenant A document. It is the only check that catches the leak before a customer does.
Q35

When is Firestore the wrong choice, and how do you migrate off it without rewriting the product?

AdvancedArchitecture

Answer

Firestore is optimised for known, indexed access patterns from many concurrent clients. It is the wrong tool whenever the workload violates that. Analytics and reporting is the clearest case: no joins, no GROUP BY, no window functions, and a per-operation bill that makes a full-table scan expensive by design, so 'let the ops team slice the data however they want' is a requirement Firestore cannot meet.

Strong relational invariants are the second case, because Firestore has no foreign keys, no unique constraints and no cross-document check constraints, so 'exactly one active subscription per user' becomes a transaction plus a sentinel document plus a nightly reconciliation job, where Postgres gives you a unique partial index. Third is sustained high write throughput concentrated on one entity, such as an inventory counter during a flash sale or a ledger with strict ordering, where the per-document write ceiling forces sharding and sharding forces you to give up the ordering you wanted. Fourth is anything needing genuine text search or complex ranking.

And fifth is the honest one: a workload where you cannot enumerate the queries up front, because in Firestore the data model and the query capability are the same decision. The migration answer interviewers actually want is that you rarely move everything. The standard first step is the firestore-bigquery-export extension or a Dataflow pipeline that mirrors collections into BigQuery, which removes the analytics pressure without touching the app.

If the transactional core genuinely needs relational guarantees, move that domain to Cloud SQL behind an API and keep Firestore for what it is uniquely good at, live UI state, presence, notifications and offline sync, running both during a dual-write period with a reconciliation job before you cut reads over. Framing it as a per-domain decision rather than a database bake-off is what a senior answer looks like.

Key Points

  • Wrong for ad-hoc analytics: no joins, no GROUP BY, per-op billing on scans
  • Wrong for hard relational invariants: no foreign keys or unique constraints
  • Wrong for concentrated high-throughput writes with ordering requirements
  • Wrong when the query set is not knowable at design time
  • Mirror to BigQuery first; it solves reporting without a migration
  • Move domains, not databases, and dual-write with reconciliation before cutting over
💡 Pro Tip: The question behind the question is whether you choose databases per workload or per habit. Say out loud which parts of the product you would leave on Firestore and why.

Companies Hiring Firestore

Google
Zoho
Meesho
Cult.fit
Physics Wallah
Tata 1mg
Urban Company
Khatabook

Salary Insights

Average in India
₹6-22 LPA

Frequently Asked Questions

What does a Firestore or Firebase developer earn in India in 2026?

Roughly ₹6-22 LPA, with the spread driven by what you own rather than by years served. Freshers building Flutter or React Native apps on Firebase typically start at ₹4-8 LPA. Engineers with two to five years who can model data for read patterns, write and test security rules, and keep the monthly bill predictable land in the ₹12-18 LPA band. Above that you are usually being paid for architecture: sharded write paths, Cloud Functions fan-out, migrations at scale and cost control across a large user base. Product companies such as Meesho, Cult.fit, Physics Wallah, Tata 1mg, Urban Company and Khatabook pay near the top of the range, and Google's own Bengaluru and Hyderabad teams sit above it. Service companies and app studios pay noticeably less for the same title, so compare offers on scope rather than on the label.

How long does it take to prepare for a Firestore interview?

If you already ship Firebase apps, two to three focused weeks is enough. Week one: reread the data-model constraints until the 1 MiB limit, subcollection semantics, index behaviour and the billing model are reflexes rather than lookups. Week two: build something that forces the hard parts, a booking flow with a real transaction, a sharded counter, cursor pagination and a rules file you have unit tested with @firebase/rules-unit-testing. Week three: run the emulator suite, write a Cloud Functions trigger that is idempotent, and use Query Explain on the Admin SDK so you can talk about read counts with numbers instead of adjectives. If you are coming from SQL, add a week purely on unlearning, because the instinct to normalise is what fails candidates. Interviewers can tell within two questions whether you have paid a Firestore bill.

What is the difference between fresher and experienced Firestore interviews?

Freshers are asked what the tool does: documents and collections, setDoc versus updateDoc, onSnapshot, basic rules, how a query is written. Getting those crisp with a working sample app on GitHub is usually enough, and interviewers forgive gaps in cost and scaling. Experienced candidates get asked what the tool costs. Expect questions on why a listener re-billed the whole collection, how you would build a counter that takes 3,000 writes a second, what happens when two clients transact on the same document, how you backfilled twenty million rows without triggering a Cloud Functions storm, and what your rules do to the read count on a list screen. The dividing line is production scar tissue. If you can name a specific incident, what the graph looked like and what you changed, you interview as senior regardless of your years.

Is Firestore still worth learning in 2026, or is it a lock-in trap?

It is worth learning, and the lock-in concern is real but overstated. Firestore remains the default backend for a very large share of consumer mobile products built in India, because a two-person team can ship an offline-capable app with live updates and no API server, and hiring demand follows that reality. The lock-in is genuine at the API level: security rules, snapshot listeners and the offline cache have no portable equivalent, so a move off Firestore is a rewrite of your data layer, not a connection-string change. What makes the skill portable is the thinking, not the SDK. Modelling for read patterns, reasoning about write amplification, designing around per-document throughput limits and treating authorisation as declarative policy all transfer directly to DynamoDB, Cosmos DB and Supabase. Learn Firestore properly and those become short ramps.

Firestore or MongoDB: which should I learn first for backend roles in India?

They look similar (both store documents) and solve different problems. MongoDB is a general-purpose database you run behind your own API, with an aggregation pipeline, joins via $lookup, flexible ad-hoc queries and full-text search built in, and it dominates Node backend job listings across Indian product and services companies. Firestore is a client-facing database with real-time listeners, offline sync and declarative authorisation, and almost no ad-hoc query capability. If you are targeting backend engineering roles broadly, learn MongoDB first: the market is larger and the query skills transfer further. If you are targeting mobile, Flutter or full-stack product roles at consumer startups, Firestore is the one that gets you shortlisted. Many teams in India run both, MongoDB for the transactional core and Firestore for live app state, so knowing where each one stops is itself an interview answer.

Do I need Google Cloud knowledge or a certification to get Firestore roles?

You need Google Cloud knowledge; you do not need a certification. Nobody in India is hired for holding the Associate Cloud Engineer badge, but plenty of candidates are rejected for not knowing that Firestore lives in a GCP project, that Cloud Monitoring is where you find out reads spiked, that IAM controls who can run gcloud firestore export, that budgets and alert policies live in Cloud Billing, and that region choice between asia-south1 and a multi-region changes both latency and price. Learn the surrounding services you will genuinely touch: Cloud Functions, Cloud Storage, Cloud Monitoring, Cloud Scheduler, BigQuery for mirroring and Secret Manager. Spend the certification effort on a deployed project instead, with committed indexes, tested rules and a monitoring dashboard you can screenshot. That evidence outperforms a badge in every Firestore interview.

Introduction

Cloud Firestore is Google's serverless document database, and by 2026 it is the default persistence layer for a very large share of consumer mobile apps built in India. Its pitch is unusual: the same database serves an offline-capable Flutter or React Native client, a web dashboard with live-updating tables, and a Cloud Functions backend, with authorisation expressed as declarative security rules instead of a hand-rolled API layer. That collapses three tiers into one, which is why small product teams reach for it. It is also why Firestore punishes engineers who treat it like a relational database: the query engine, the pricing model and the write throughput ceiling all behave in ways SQL habits do not predict.

Firestore interviews in India are rarely about syntax. Interviewers assume you can call getDocs and setDoc, then spend the round probing whether you understand the parts that cost money and cause outages: how document reads are billed, why a snapshot listener quietly re-reads an entire result set, what the one-write-per-second-per-document soft limit does to a naive counter, when a transaction retries and what that means for the code inside it, and how security rules are evaluated (and billed) when they call get() on another document. Data modelling questions dominate, because in Firestore the schema decision and the query capability are the same decision.

This guide works through 35 Firestore interview questions asked in 2026, ordered from fundamentals to production architecture. Fourteen basic questions cover the data model, the modular SDK, indexes, billing and the emulator. Fourteen intermediate questions go into transactions, atomic field operations, cursors, aggregation queries, multi-inequality filtering, offline persistence and rules testing. Seven advanced questions cover hotspotting, cost incidents, backups and TTL, vector search, large backfills, multi-tenant isolation, and knowing when Firestore is the wrong tool. Most answers carry runnable code against the modular Firebase JS SDK or the Node Admin SDK.

Ready to practice Firestore interviews?

Don't just read, practice these Firestore questions live with an AI interviewer that asks follow-ups and scores your answers.

AI-powered practice
Instant feedback
Free to start
Start Free Mock Interview