Google Cloud Firestore: Serverless Document Database for Web and Mobile Apps
Firestore is a document database that eliminates infrastructure management entirely. You do not provision nodes, configure replication, or set up connection pools. You write documents to collections, attach real-time listeners, and Firestore pushes updates to all connected clients automatically. The database scales to accommodate any traffic level without configuration changes.
This guide covers the data model, real-time listeners, offline support, security rules, composite indexes for complex queries, and the difference between Firestore in Native mode and Datastore mode.
The Data Model: Collections and Documents
Firestore organizes data as documents nested inside collections. A document is a JSON-like object with typed fields. Documents live inside collections, and documents can contain subcollections.
Firestore database├── collection: "users"│ ├── document: "user_abc123"│ │ ├── name: "Alice Chen"│ │ ├── email: "alice@example.com"│ │ ├── created_at: Timestamp(2025-01-15T09:00:00Z)│ │ └── subcollection: "orders"│ │ ├── document: "order_001"│ │ │ ├── total: 89.99│ │ │ └── status: "shipped"│ │ └── document: "order_002"│ │ ├── total: 124.50│ │ └── status: "pending"│ └── document: "user_def456"│ └── ...└── collection: "products" └── ...Unlike a relational database, you do not define a schema in advance. Different documents in the same collection can have different fields. Firestore supports these field types: string, number, boolean, null, timestamp, geopoint, reference, array, and map.
Reading and Writing Documents
The Firebase SDK is the standard way to interact with Firestore from web and mobile applications. The Admin SDK (server-side) and the Cloud Client Library handle backend operations.
// Web SDK (Firebase v9 modular API)import { initializeApp } from "firebase/app";import { getFirestore, doc, setDoc, getDoc, collection, query, where, getDocs, onSnapshot,} from "firebase/firestore";
const app = initializeApp(firebaseConfig);const db = getFirestore(app);
// Write a documentawait setDoc(doc(db, "users", "user_abc123"), { name: "Alice Chen", email: "alice@example.com", tier: "premium", createdAt: new Date(),});
// Read a single documentconst docRef = doc(db, "users", "user_abc123");const docSnap = await getDoc(docRef);if (docSnap.exists()) { console.log("User data:", docSnap.data());}
// Query documentsconst q = query( collection(db, "users"), where("tier", "==", "premium"), where("createdAt", ">=", new Date("2025-01-01")));const querySnapshot = await getDocs(q);querySnapshot.forEach((doc) => { console.log(doc.id, doc.data());});Real-Time Listeners: The Core Differentiator
Firestore’s real-time listener capability is what makes it compelling for mobile and collaborative applications. When data changes in the database, Firestore pushes the update to all clients listening on that document or query.
// Listen to a document — fires on every changeconst unsubscribe = onSnapshot( doc(db, "orders", "order_001"), (docSnap) => { console.log("Order updated:", docSnap.data()); // Update UI automatically });
// Listen to a query — fires whenever any matching document changesconst q = query( collection(db, "orders"), where("assignedDriver", "==", "driver_007"));const unsubscribeQuery = onSnapshot(q, (querySnapshot) => { querySnapshot.docChanges().forEach((change) => { if (change.type === "added") console.log("New order:", change.doc.data()); if (change.type === "modified") console.log("Updated:", change.doc.data()); if (change.type === "removed") console.log("Removed:", change.doc.id); });});
// Stop listening when component unmountsunsubscribe();This push model means your UI reflects database state without polling. A ride-sharing app showing driver location, a collaborative document editor, or a live sports scoreboard all benefit from this pattern.
Offline Support
Firestore SDKs for iOS, Android, and web cache data locally. When the device loses connectivity, reads serve from the local cache and writes queue locally. When connectivity restores, queued writes sync to the server automatically.
// Enable offline persistence (web)import { enableIndexedDbPersistence } from "firebase/firestore";
enableIndexedDbPersistence(db).catch((err) => { if (err.code === "failed-precondition") { // Multiple tabs open — persistence only works in one tab at a time }});
// Check connection stateimport { onSnapshotsInSync } from "firebase/firestore";onSnapshotsInSync(db, () => { console.log("All listeners in sync with backend");});This offline capability makes Firestore well-suited for mobile apps that need to work in areas with poor connectivity — field service apps, inspection tools, or delivery applications.
Security Rules: Database-Level Authorization
Firestore Security Rules define who can read and write which documents. Rules run on the Firestore server and cannot be bypassed by client code.
rules_version = '2';service cloud.firestore { match /databases/{database}/documents {
// Users can only read/write their own profile match /users/{userId} { allow read, write: if request.auth != null && request.auth.uid == userId; }
// Orders: users can read their own, admins can read all match /orders/{orderId} { allow read: if request.auth != null && (resource.data.userId == request.auth.uid || request.auth.token.admin == true); allow create: if request.auth != null && request.resource.data.userId == request.auth.uid && request.resource.data.total > 0; allow update: if request.auth.token.admin == true; }
// Product catalog is publicly readable match /products/{productId} { allow read: if true; allow write: if request.auth.token.admin == true; } }}Security rules use request.auth (the authenticated user’s token), request.resource.data (the data being written), and resource.data (existing document data). They replace backend authorization middleware for client-facing applications.
Composite Indexes for Complex Queries
Firestore automatically creates single-field indexes. When you query with multiple filters or combine a filter with an order-by clause, Firestore needs a composite index. Without it, the query returns an error with a link to create the required index.
Query: where("status", "==", "pending") .where("region", "==", "EMEA") .orderBy("createdAt", "desc")
Required composite index: Collection: orders Fields: status ASC, region ASC, createdAt DESCCreate composite indexes in the Firebase console, via the firebase.json configuration, or by clicking the link in the error message during development.
{ "indexes": [ { "collectionGroup": "orders", "queryScope": "COLLECTION", "fields": [ { "fieldPath": "status", "order": "ASCENDING" }, { "fieldPath": "region", "order": "ASCENDING" }, { "fieldPath": "createdAt", "order": "DESCENDING" } ] } ]}Firestore Native Mode vs Datastore Mode
Firestore runs in two modes:
┌─────────────────────────────┬──────────────────────────────────────────────────┐│ Native Mode │ Datastore Mode │├─────────────────────────────┼──────────────────────────────────────────────────┤│ Supports real-time listeners│ No real-time listeners ││ Web/mobile SDKs available │ Server-side only (Datastore API) ││ Offline sync │ No offline sync ││ Security rules │ Cloud IAM only ││ New projects should use │ Legacy projects migrating from Datastore │└─────────────────────────────┴──────────────────────────────────────────────────┘New projects should use Native mode. Datastore mode exists for backward compatibility with applications built on Cloud Datastore before Firestore replaced it.
Transactions and Batch Writes
Firestore transactions read and write atomically. If any document read inside the transaction has been modified by another client before the transaction commits, Firestore retries the transaction automatically.
import { runTransaction, writeBatch } from "firebase/firestore";
// Transaction: read-then-write patternawait runTransaction(db, async (transaction) => { const stockRef = doc(db, "inventory", "PROD-001"); const stockDoc = await transaction.get(stockRef);
if (!stockDoc.exists()) throw new Error("Product not found"); const currentStock = stockDoc.data().quantity; if (currentStock < 1) throw new Error("Out of stock");
transaction.update(stockRef, { quantity: currentStock - 1 }); transaction.set(doc(collection(db, "orders")), { productId: "PROD-001", userId: currentUser.uid, quantity: 1, status: "confirmed", createdAt: new Date(), });});
// Batch write: multiple writes committed atomically (no reads)const batch = writeBatch(db);batch.set(doc(db, "stats", "daily"), { date: today, orders: count });batch.update(doc(db, "users", userId), { lastOrderAt: new Date() });await batch.commit();Summary
Firestore’s strength is in client-facing applications where real-time updates, offline support, and client-side authorization rules simplify the architecture significantly. Instead of building an API layer to handle authorization and push updates to clients, Firestore handles both natively. The trade-offs are query limitations (no ad-hoc joins, no aggregate queries without summary documents) and a pricing model based on reads, writes, and deletes — heavy-read dashboards can become expensive. For those access patterns, export data to BigQuery and query it there. Firestore excels as the operational data store for web and mobile apps; BigQuery excels as the analytical layer.