Firebase DB (Firestore / Realtime Database)
Cloud-hosted NoSQL from Google with built-in real-time synchronization: CRUD plus live onSnapshot listeners, declarative queries, server-enforced security rules, and transparent offline persistence — a backend-as-a-service that removes the transport, auth and reconnection plumbing.
What Firebase gives you. Firebase is a backend-as-a-service. Its two databases — Cloud Firestore (the modern default) and the older Realtime Database (RTDB) — are cloud-hosted NoSQL stores with real-time synchronization baked in. You do not run a server, open sockets, or manage reconnection; you import the SDK, initialize your app config, and read/write data with helper functions. The database itself pushes changes to every connected client.
Firestore's data model. Firestore stores documents (JSON-like maps with typed fields) inside collections. Documents can contain subcollections, forming a hierarchy. There are no rows or tables and no server-side JOINs — you model for your reads, often denormalizing. A document is addressed by a path like collection/docId/subcollection/docId. This document/collection shape is what enables Firestore's rich, indexed querying, which RTDB (a single big JSON tree) lacks.
The modular SDK. Modern Firebase (v9+) is tree-shakeable and functional: instead of db.collection('x').where(...), you import only the functions you use — initializeApp, getFirestore, collection, doc, addDoc, setDoc, updateDoc, deleteDoc, getDoc, getDocs, query, where, orderBy, limit, and onSnapshot. This keeps your bundle small because unused features are dropped by the bundler.
The defining feature: real-time listeners. Instead of polling, you subscribe to a document or query with onSnapshot. The callback fires immediately with the current data and again on every subsequent change — inserts, updates, and deletes to matching documents — keeping your local UI in perfect sync with the server automatically. This push model is the whole reason to reach for Firebase over a plain REST API.
Reading the snapshot correctly. The snapshot passed to your callback has .docs (each with .id and .data()), plus .metadata.hasPendingWrites (true while a local write is not yet acknowledged by the server) and .docChanges() (the exact added/modified/removed deltas, useful for animating lists). You typically map snapshot.docs into your state, merging the document id with its data.
You MUST unsubscribe. onSnapshot returns an unsubscribe function. In React you return it from useEffect so the listener is torn down when the component unmounts or when the query dependencies change. Forgetting this leaks listeners, causes duplicate updates and stale UI, and keeps consuming reads against your billing quota. This is the single most common Firebase bug in React apps.
Writes: add, set, update, delete. addDoc(collection, data) creates a document with an auto-generated id. setDoc(doc(db, 'c', id), data) writes at a known id (with { merge: true } to patch instead of overwrite). updateDoc(doc(...), partial) patches specific fields. deleteDoc(doc(...)) removes it. For counters and arrays use field transforms: increment(1), arrayUnion(x), arrayRemove(x), and serverTimestamp() for a trustworthy server-side time.
Querying and its constraints. You compose queries declaratively: query(collection(db, 'expenses'), where('userId', '==', uid), orderBy('createdAt', 'desc'), limit(20)). Firestore is fast because every query is index-backed — but compound queries require composite indexes, and the SDK will throw an error with a one-click link to create the missing index. Firestore forbids inefficient queries by design: no OR across different fields historically (now limited or() support), no full-text search (use Algolia/Typesense), and range filters on only one field.
Security rules run on the server. Because the client talks straight to the database, Firestore Security Rules are your real authorization layer — they are enforced server-side and cannot be bypassed by a tampered client. Rules are declarative: you match document paths and allow read/write conditioned on request.auth.uid, the incoming data, and the existing document. Never trust the client; validate shape and ownership in rules, not just in your UI. This is the mental shift from a traditional backend where your controllers guard access.
Offline persistence for free. Firestore caches data locally and queues writes while offline, then syncs when connectivity returns — onSnapshot even fires against the local cache first, so the app feels instant and works on a plane. You enable it with persistent local cache settings (IndexedDB-backed). hasPendingWrites lets you show an 'unsynced' indicator. RTDB has similar offline support. This is transparent offline-first with almost no code.
Firestore vs Realtime Database. RTDB is one giant JSON tree optimized for very low-latency, simple state sync — presence, typing indicators, live cursors, simple counters — and bills by bandwidth and connections. Firestore is a document/collection model with richer indexed queries, better horizontal scaling, stronger consistency, and per-operation billing. Default to Firestore for structured app data; drop to RTDB (or use both) only when you need the absolute lowest-latency simple sync or presence. Cost, indexing needs, and query shape drive the choice.
Costs and pitfalls to name in interviews. Firestore bills per document read/write/delete, so a listener over a large collection or a chatty component can rack up reads fast — scope queries with where/limit, and cache. Watch for unbounded listeners, the missing-composite-index error, denormalization drift (the same data copied in several places going stale), and the lack of native full-text search. Model data around the queries you actually run.
The mental model (memorise this). Firebase is a cloud NoSQL database you talk to directly from the client: onSnapshot gives you push-based real-time updates (always return its unsubscribe), writes are one-line add/set/update/delete with server-side field transforms, queries are declarative but index-backed and constrained, offline persistence and reconnection are handled for you, and — because there is no server in between — Security Rules enforced on the server are your real authorization layer.
A Firestore real-time listener is the managed equivalent of subscribing to a database change stream — Postgres `LISTEN/NOTIFY`, a Debezium/CDC feed, or a Kafka topic of change events — except Firebase owns the WebSocket transport, auth token refresh, and reconnection so you never write that plumbing. `addDoc`/`updateDoc`/`deleteDoc` map onto your Spring `JpaRepository` save/delete methods, and `serverTimestamp()` is `@CreationTimestamp` filled by the DB rather than a spoofable client clock. The big conceptual shift is authorization: with Spring you guard access in `@PreAuthorize` and service methods on a server you control, but with Firebase the client hits the database directly, so **Security Rules are your controller-layer authorization** — declarative, server-enforced, and impossible to bypass from a tampered client. Offline persistence with a write queue is exactly the outbox pattern you would build with Vert.x/Kafka for at-least-once delivery, here provided out of the box.
- Firebase is a backend-as-a-service; Firestore and the Realtime Database are cloud-hosted NoSQL stores with real-time sync built in.
- Firestore models data as documents inside collections (with subcollections); there are no tables or server-side JOINs, so you model for your reads and often denormalize.
- The modular v9+ SDK is tree-shakeable and functional — you import only the helpers you use, keeping bundles small.
- onSnapshot is push-based: the callback fires on first load and again on every matching change, so you never poll.
- onSnapshot returns an unsubscribe function that you MUST call (return it from useEffect) or you leak listeners and burn read quota.
- Writes are one-liners: addDoc (auto id), setDoc (known id, merge to patch), updateDoc (patch fields), deleteDoc; use increment/arrayUnion/serverTimestamp field transforms.
- Queries are declarative (query + where + orderBy + limit) but every query is index-backed; compound queries need composite indexes and there is no native full-text search.
- Security Rules run on the server and are your real authorization layer — the client talks directly to the DB, so never trust it.
- Firestore caches data and queues writes offline, syncing on reconnect; hasPendingWrites signals unsynced local writes.
- Firestore = structured docs, rich queries, per-operation billing; RTDB = one JSON tree, lowest-latency simple sync/presence, bandwidth billing.
Worked Code
import { initializeApp } from 'firebase/app';
import {
getFirestore, collection, addDoc, onSnapshot,
query, where, orderBy, limit, doc, updateDoc, deleteDoc, serverTimestamp,
} from 'firebase/firestore';
import { useEffect, useState } from 'react';
const app = initializeApp({
apiKey: import.meta.env.VITE_FIREBASE_API_KEY,
projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID,
// ...rest of config
});
const db = getFirestore(app);
type Expense = { id: string; userId: string; amount: number; category: string };
function useExpenses(userId: string) {
const [expenses, setExpenses] = useState<Expense[]>([]);
useEffect(() => {
// Declarative query — index-backed. Compound where+orderBy needs a composite index.
const q = query(
collection(db, 'expenses'),
where('userId', '==', userId),
orderBy('createdAt', 'desc'),
limit(50),
);
// Push-based: fires immediately, then on EVERY matching change.
const unsubscribe = onSnapshot(q, (snapshot) => {
setExpenses(
snapshot.docs.map((d) => ({ id: d.id, ...d.data() }) as Expense),
);
if (snapshot.metadata.hasPendingWrites) {
console.log('local write not yet acknowledged by the server');
}
});
return unsubscribe; // CRITICAL cleanup — tear the listener down on unmount / userId change
}, [userId]);
return expenses;
}import {
collection, addDoc, setDoc, updateDoc, deleteDoc, doc,
increment, arrayUnion, serverTimestamp,
} from 'firebase/firestore';
// CREATE with an auto-generated id
async function addExpense(userId: string, amount: number, category: string) {
const ref = await addDoc(collection(db, 'expenses'), {
userId, amount, category,
createdAt: serverTimestamp(), // trustworthy server clock, not the device clock
tags: [],
});
return ref.id;
}
// SET at a known id (merge: true patches instead of overwriting the whole doc)
const upsertProfile = (uid: string, data: Record<string, unknown>) =>
setDoc(doc(db, 'profiles', uid), data, { merge: true });
// UPDATE specific fields, including atomic field transforms
const approve = (id: string) =>
updateDoc(doc(db, 'expenses', id), {
status: 'approved',
reviewCount: increment(1), // atomic server-side counter
tags: arrayUnion('reviewed'), // atomic array add (no read-modify-write race)
});
// DELETE
const removeExpense = (id: string) => deleteDoc(doc(db, 'expenses', id));rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
// Users may only read/write their OWN expenses.
match /expenses/{expenseId} {
// Read only your own documents.
allow read: if request.auth != null
&& resource.data.userId == request.auth.uid;
// Create: must be signed in, own the doc, and send a valid shape.
allow create: if request.auth != null
&& request.resource.data.userId == request.auth.uid
&& request.resource.data.amount is number
&& request.resource.data.amount > 0;
// Update/delete only your own; forbid changing the owner.
allow update, delete: if request.auth != null
&& resource.data.userId == request.auth.uid
&& request.resource.data.userId == resource.data.userId;
}
}
}
// These run on Google's servers and CANNOT be bypassed by a tampered client.import { initializeApp } from 'firebase/app';
import {
initializeFirestore, persistentLocalCache, persistentMultipleTabManager,
} from 'firebase/firestore';
const app = initializeApp({ /* config */ });
// Persistent cache: data is cached in IndexedDB and writes are queued while offline,
// then flushed on reconnect. Multi-tab manager keeps several tabs consistent.
const db = initializeFirestore(app, {
localCache: persistentLocalCache({
tabManager: persistentMultipleTabManager(),
}),
});
// onSnapshot now serves cached data first (instant + offline), then server updates.
// snapshot.metadata.fromCache tells you whether data came from the local cache.
export { db };Interview-Ready Q&A
You subscribe to a document or query with onSnapshot. Firestore keeps a persistent connection open and pushes the initial snapshot plus a new snapshot whenever any matching document changes. It is push-based, so you get sub-second updates without burning requests on polling, and only the deltas travel over the wire. Polling wastes bandwidth, adds latency equal to the poll interval, scales poorly, and still misses changes between polls.
- 1Firebase = backend-as-a-service; Firestore (docs/collections) and RTDB (one JSON tree) are NoSQL with real-time sync.
- 2Use the modular v9+ SDK — import only the helpers you use for small bundles.
- 3onSnapshot = push-based real-time; it fires on first load and on every matching change.
- 4Always return the unsubscribe function from useEffect to avoid leaked listeners and wasted reads.
- 5Writes: addDoc (auto id) / setDoc (known id, merge to patch) / updateDoc / deleteDoc.
- 6Use serverTimestamp(), increment(), arrayUnion()/arrayRemove() for trustworthy, atomic server-side updates.
- 7Queries are index-backed; compound queries need composite indexes and there is no native full-text search.
- 8Security Rules run on the server and are your real authorization layer — never trust the client.
- 9Persistent local cache queues writes offline and syncs on reconnect; check hasPendingWrites / fromCache.
- 10Firestore bills per read/write/delete — scope with where/limit and cache to control cost.