Firestore real-time listeners use onSnapshot to push data changes to your app instantly without polling. Call onSnapshot on a document reference or query to receive the initial data and then every subsequent change. Each snapshot includes metadata indicating whether the data came from the server or local cache. Always store the unsubscribe function and call it when the component unmounts to prevent memory leaks and unnecessary reads.
Listening to Real-Time Updates in Firestore
One of Firestore's most powerful features is real-time synchronization. Instead of fetching data once and manually refreshing, you attach a listener that fires every time the data changes on the server or in the local cache. This tutorial covers listening to individual documents and queries, understanding snapshot metadata, handling errors, and cleaning up listeners in React applications.
Prerequisites
- A Firebase project with Firestore enabled
- Firebase JS SDK v9+ installed in your project
- A Firestore collection with at least a few documents to observe
- Basic understanding of Firestore document and collection references
Step-by-step guide
Listen to a single document with onSnapshot
Listen to a single document with onSnapshot
Call onSnapshot on a document reference to receive real-time updates whenever that document changes. The callback fires immediately with the current document state and again each time the document is created, updated, or deleted. Check snapshot.exists() to handle documents that do not exist yet. The function returns an unsubscribe callback you must call to stop listening.
1import { getFirestore, doc, onSnapshot } from 'firebase/firestore'23const db = getFirestore()45const unsubscribe = onSnapshot(6 doc(db, 'users', 'user-123'),7 (snapshot) => {8 if (snapshot.exists()) {9 const data = snapshot.data()10 console.log('User data:', data)11 console.log('Document ID:', snapshot.id)12 } else {13 console.log('Document does not exist')14 }15 },16 (error) => {17 console.error('Listener error:', error.message)18 }19)2021// Call unsubscribe() when you no longer need updatesExpected result: The callback fires immediately with the current document, then again whenever the document changes in Firestore.
Listen to a query for collection-level updates
Listen to a query for collection-level updates
Attach onSnapshot to a query to get real-time updates for all matching documents. The snapshot contains a docs array with every document that matches the query at that moment. Use snapshot.docChanges() to see only the documents that changed since the last snapshot, which is more efficient for updating a list in your UI.
1import { collection, query, where, orderBy, limit, onSnapshot } from 'firebase/firestore'23const q = query(4 collection(db, 'messages'),5 where('roomId', '==', 'room-abc'),6 orderBy('createdAt', 'desc'),7 limit(50)8)910const unsubscribe = onSnapshot(q, (snapshot) => {11 // Full list of matching documents12 const messages = snapshot.docs.map((doc) => ({13 id: doc.id,14 ...doc.data(),15 }))1617 // Only the changes since last snapshot18 snapshot.docChanges().forEach((change) => {19 if (change.type === 'added') console.log('New:', change.doc.data())20 if (change.type === 'modified') console.log('Updated:', change.doc.data())21 if (change.type === 'removed') console.log('Removed:', change.doc.id)22 })23})Expected result: The callback fires with the initial set of matching documents and again whenever a matching document is added, modified, or removed.
Use snapshot metadata to detect source and pending writes
Use snapshot metadata to detect source and pending writes
Each snapshot includes metadata that tells you whether the data came from the server or the local cache, and whether there are pending writes that have not been confirmed by the server yet. This is useful for showing optimistic UI indicators like 'Saving...' or distinguishing between local changes and server-confirmed data. Pass { includeMetadataChanges: true } to receive extra callbacks for metadata-only changes.
1const unsubscribe = onSnapshot(2 doc(db, 'posts', 'post-1'),3 { includeMetadataChanges: true },4 (snapshot) => {5 const source = snapshot.metadata.fromCache ? 'cache' : 'server'6 const pending = snapshot.metadata.hasPendingWrites78 console.log(`Data from ${source}, pending writes: ${pending}`)910 if (pending) {11 // Show "Saving..." indicator12 } else {13 // Data confirmed by server14 }1516 const data = snapshot.data()17 console.log(data)18 }19)Expected result: The listener fires for both data changes and metadata changes, allowing you to show real-time save status indicators.
Unsubscribe from listeners in React with useEffect
Unsubscribe from listeners in React with useEffect
In React, attach the listener inside useEffect and return the unsubscribe function as the cleanup. This ensures the listener is removed when the component unmounts or when dependencies change. Failing to unsubscribe causes memory leaks and continues billing for document reads in the background.
1import { useState, useEffect } from 'react'2import { doc, onSnapshot } from 'firebase/firestore'34function UserProfile({ userId }: { userId: string }) {5 const [profile, setProfile] = useState<any>(null)6 const [loading, setLoading] = useState(true)78 useEffect(() => {9 const unsubscribe = onSnapshot(10 doc(db, 'users', userId),11 (snapshot) => {12 setProfile(snapshot.exists() ? { id: snapshot.id, ...snapshot.data() } : null)13 setLoading(false)14 },15 (error) => {16 console.error('Listener error:', error)17 setLoading(false)18 }19 )2021 // Cleanup: unsubscribe when component unmounts or userId changes22 return () => unsubscribe()23 }, [userId])2425 if (loading) return <div>Loading...</div>26 if (!profile) return <div>User not found</div>27 return <div>{profile.displayName}</div>28}Expected result: The component shows real-time profile data and properly cleans up the listener on unmount.
Handle offline behavior and reconnection
Handle offline behavior and reconnection
Firestore listeners work offline when persistence is enabled. The first callback fires with cached data, and when the device reconnects, Firestore syncs and fires again with server data. Enable persistent cache on web with persistentLocalCache to support offline reads. The snapshot metadata tells you whether data came from cache or server.
1import { initializeFirestore, persistentLocalCache, persistentMultipleTabManager } from 'firebase/firestore'23// Enable persistent cache for offline support4const db = initializeFirestore(app, {5 localCache: persistentLocalCache({6 tabManager: persistentMultipleTabManager(),7 }),8})910// Listener works offline — fires with cached data11const unsubscribe = onSnapshot(12 collection(db, 'tasks'),13 (snapshot) => {14 const fromCache = snapshot.metadata.fromCache15 console.log(fromCache ? 'Offline data' : 'Server data')1617 const tasks = snapshot.docs.map((d) => ({ id: d.id, ...d.data() }))18 console.log(tasks)19 }20)Expected result: Listeners fire with cached data when offline and sync automatically when the connection is restored.
Complete working example
1import { useState, useEffect } from 'react'2import { initializeApp } from 'firebase/app'3import {4 getFirestore,5 doc,6 collection,7 query,8 onSnapshot,9 QueryConstraint,10 DocumentData,11} from 'firebase/firestore'1213const app = initializeApp({14 apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY!,15 authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN!,16 projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID!,17})1819const db = getFirestore(app)2021export function useDocument<T = DocumentData>(path: string, id: string) {22 const [data, setData] = useState<(T & { id: string }) | null>(null)23 const [loading, setLoading] = useState(true)24 const [error, setError] = useState<Error | null>(null)2526 useEffect(() => {27 setLoading(true)28 const unsubscribe = onSnapshot(29 doc(db, path, id),30 (snap) => {31 setData(snap.exists() ? { id: snap.id, ...(snap.data() as T) } : null)32 setLoading(false)33 },34 (err) => { setError(err); setLoading(false) }35 )36 return () => unsubscribe()37 }, [path, id])3839 return { data, loading, error }40}4142export function useCollection<T = DocumentData>(43 path: string,44 ...constraints: QueryConstraint[]45) {46 const [data, setData] = useState<(T & { id: string })[]>([])47 const [loading, setLoading] = useState(true)48 const [error, setError] = useState<Error | null>(null)4950 useEffect(() => {51 setLoading(true)52 const q = query(collection(db, path), ...constraints)53 const unsubscribe = onSnapshot(54 q,55 (snap) => {56 setData(snap.docs.map((d) => ({ id: d.id, ...(d.data() as T) })))57 setLoading(false)58 },59 (err) => { setError(err); setLoading(false) }60 )61 return () => unsubscribe()62 }, [path, ...constraints])6364 return { data, loading, error }65}Common mistakes when listening to Real-Time Updates in Firestore
Why it's a problem: Not unsubscribing from listeners when a component unmounts, causing memory leaks and phantom reads
How to avoid: Always return the unsubscribe function from useEffect cleanup. Store it in a ref if you need to unsubscribe outside the effect.
Why it's a problem: Attaching a new listener on every render because the query object is recreated each time
How to avoid: Memoize query constraints with useMemo or define the query outside the component to avoid infinite listener attach/detach cycles.
Why it's a problem: Ignoring the error callback, causing listeners to silently fail on permission denied errors
How to avoid: Always pass an error callback as the third argument to onSnapshot. Log the error and show a user-friendly message.
Why it's a problem: Using onSnapshot when a one-time read with getDoc would suffice, wasting reads on data that rarely changes
How to avoid: Use onSnapshot only for data that changes frequently and needs to appear instantly. Use getDoc for static reference data.
Best practices
- Always store and call the unsubscribe function when the listener is no longer needed
- Use docChanges() for efficient list rendering instead of re-rendering the entire list on every snapshot
- Include an error callback to catch permission-denied and other listener errors
- Enable includeMetadataChanges when you need to show save status or online/offline indicators
- Combine onSnapshot with limit() to avoid listening to unbounded collections that grow over time
- Use persistent local cache on web for offline support with real-time listeners
- Wrap listener logic in reusable hooks (useDocument, useCollection) for consistency across your app
Still stuck?
Copy one of these prompts to get a personalized, step-by-step explanation.
Show me how to use Firestore onSnapshot in a React app with the modular SDK v9. I need to listen to both a single document and a filtered query in real time, handle errors, unsubscribe on unmount, and detect whether data came from cache or server.
Create a real-time chat component that uses Firestore onSnapshot to listen for new messages in a room. Show messages ordered by timestamp, use docChanges() to efficiently append new messages, and clean up the listener when the user leaves the room.
Frequently asked questions
Does onSnapshot count as a document read on every callback?
The initial callback counts one read per document returned. Subsequent callbacks only count reads for documents that actually changed, not for the entire result set.
Can I use onSnapshot with collection group queries?
Yes. Attach onSnapshot to a collectionGroup query to listen to all subcollections with the same name across your database. Make sure you have the required collection group index.
What happens to a listener when the user goes offline?
The listener continues to work with cached data if persistence is enabled. When the device reconnects, Firestore syncs changes and fires the callback with updated server data.
How do I limit the number of documents a listener watches?
Add limit() to your query before passing it to onSnapshot. For example, query(collection(db, 'messages'), orderBy('createdAt', 'desc'), limit(50)) listens to only the 50 most recent messages.
Is onSnapshot suitable for large datasets with thousands of documents?
Listening to thousands of documents at once is expensive in terms of reads and memory. Use queries with filters and limits to narrow the listener scope. For large datasets, consider paginated one-time reads instead.
Can RapidDev help build real-time features with Firestore listeners?
Yes. RapidDev can architect efficient real-time data flows using Firestore listeners, including optimistic UI updates, proper cleanup patterns, and cost-optimized query structures.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation