Firebase Firestore Integration in Mobile Applications
Firestore is a document-oriented database with real-time listeners, offline caching, and flexible queries. Unlike RTDB, it supports composite indexes, where() on multiple fields, and orderBy() with pagination. However, it has its own pitfalls: onSnapshot without limit() on a growing collection gradually increases data volume on each tick until rendering slows down.
Data Structure: Collections and Subcollections
Basic structure for a social application:
users/{userId}
├── displayName: string
├── photoURL: string
└── posts/{postId} ← subcollection
├── text: string
├── createdAt: Timestamp
└── likes: number
Subcollections are correct for data with unlimited quantity. Nested arrays in documents are incorrect for collections > 50 elements: Firestore limits document size to 1 MB, and the entire document is read even if you need one field.
Subscriptions and Pagination
import firestore from '@react-native-firebase/firestore';
// Real-time + pagination
const [posts, setPosts] = useState<Post[]>([]);
const [lastDoc, setLastDoc] = useState<FirebaseFirestoreTypes.DocumentSnapshot | null>(null);
const [loading, setLoading] = useState(false);
const fetchPage = useCallback(async () => {
if (loading) return;
setLoading(true);
let query = firestore()
.collection(`users/${userId}/posts`)
.orderBy('createdAt', 'desc')
.limit(20);
if (lastDoc) query = query.startAfter(lastDoc);
const snapshot = await query.get();
const newPosts = snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() } as Post));
setPosts(prev => [...prev, ...newPosts]);
setLastDoc(snapshot.docs[snapshot.docs.length - 1] ?? null);
setLoading(false);
}, [lastDoc, loading, userId]);
// Real-time: only for top of feed (without pagination)
useEffect(() => {
const unsubscribe = firestore()
.collection(`users/${userId}/posts`)
.orderBy('createdAt', 'desc')
.limit(10)
.onSnapshot(snapshot => {
const fresh = snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() } as Post));
setPosts(prev => {
// Combine new real-time data with paginated
const ids = new Set(fresh.map(p => p.id));
return [...fresh, ...prev.filter(p => !ids.has(p.id))];
});
});
return () => unsubscribe();
}, [userId]);
Separate real-time (first page) and load more (pagination via get()). onSnapshot on the entire list with pagination is an antipattern: each collection change returns a complete new snapshot of the first N documents.
Offline Cache
// Enable before any Firestore access
await firestore().settings({
cacheSizeBytes: firestore.CACHE_SIZE_UNLIMITED, // or specific size in bytes
persistence: true, // enabled by default on mobile
});
When offline, onSnapshot continues to work, returning data from cache with snapshot.metadata.fromCache === true. Writes are buffered and sent on network recovery.
For explicit cache reading without network request:
const snapshot = await firestore()
.collection('posts')
.doc(postId)
.get({ source: 'cache' }); // 'cache' | 'server' | 'default'
Composite Indexes
Queries with where + orderBy on different fields require a composite index. Firestore automatically suggests it on first error in development (console link). In production, configure indexes in firestore.indexes.json beforehand:
{
"indexes": [
{
"collectionGroup": "posts",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "userId", "order": "ASCENDING" },
{ "fieldPath": "createdAt", "order": "DESCENDING" }
]
}
]
}
Without an index, Firestore returns FAILED_PRECONDITION error. Index creation time ranges from minutes to hours on large collections.
Firestore Security Rules
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /users/{userId}/posts/{postId} {
allow read: if request.auth != null;
allow write: if request.auth.uid == userId
&& request.resource.data.keys().hasAll(['text', 'createdAt'])
&& request.resource.data.text is string
&& request.resource.data.text.size() <= 2000;
}
}
}
Validate types and sizes in rules, not just client code.
Transactions and Batch Writes
// Transaction: atomic like toggle
await firestore().runTransaction(async transaction => {
const postRef = firestore().doc(`posts/${postId}`);
const userLikeRef = firestore().doc(`userLikes/${userId}_${postId}`);
const [postSnap, likeSnap] = await Promise.all([
transaction.get(postRef),
transaction.get(userLikeRef),
]);
if (likeSnap.exists()) {
transaction.delete(userLikeRef);
transaction.update(postRef, { likes: firestore.FieldValue.increment(-1) });
} else {
transaction.set(userLikeRef, { userId, postId, createdAt: firestore.FieldValue.serverTimestamp() });
transaction.update(postRef, { likes: firestore.FieldValue.increment(1) });
}
});
FieldValue.increment() is atomic increment without read-modify-write race. Without transaction, concurrent likes would have incorrect counter.
Estimation
Firestore with offline persistence, real-time subscriptions, pagination, and security rules: 2–4 weeks depending on data structure complexity.







