Skip to main content
RapidDev - Software Development Agency
firebase-tutorial

How to Listen to Real-Time Updates in Firestore

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.

What you'll learn

  • How to listen to single document changes with onSnapshot
  • How to listen to query results in real time
  • How to handle snapshot metadata and error callbacks
  • How to properly unsubscribe listeners to prevent memory leaks
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Intermediate8 min read12-18 minFirebase JS SDK v9+, all plansMarch 2026RapidDev Engineering Team
TL;DR

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

1

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.

typescript
1import { getFirestore, doc, onSnapshot } from 'firebase/firestore'
2
3const db = getFirestore()
4
5const 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)
20
21// Call unsubscribe() when you no longer need updates

Expected result: The callback fires immediately with the current document, then again whenever the document changes in Firestore.

2

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.

typescript
1import { collection, query, where, orderBy, limit, onSnapshot } from 'firebase/firestore'
2
3const q = query(
4 collection(db, 'messages'),
5 where('roomId', '==', 'room-abc'),
6 orderBy('createdAt', 'desc'),
7 limit(50)
8)
9
10const unsubscribe = onSnapshot(q, (snapshot) => {
11 // Full list of matching documents
12 const messages = snapshot.docs.map((doc) => ({
13 id: doc.id,
14 ...doc.data(),
15 }))
16
17 // Only the changes since last snapshot
18 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.

3

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.

typescript
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.hasPendingWrites
7
8 console.log(`Data from ${source}, pending writes: ${pending}`)
9
10 if (pending) {
11 // Show "Saving..." indicator
12 } else {
13 // Data confirmed by server
14 }
15
16 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.

4

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.

typescript
1import { useState, useEffect } from 'react'
2import { doc, onSnapshot } from 'firebase/firestore'
3
4function UserProfile({ userId }: { userId: string }) {
5 const [profile, setProfile] = useState<any>(null)
6 const [loading, setLoading] = useState(true)
7
8 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 )
20
21 // Cleanup: unsubscribe when component unmounts or userId changes
22 return () => unsubscribe()
23 }, [userId])
24
25 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.

5

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.

typescript
1import { initializeFirestore, persistentLocalCache, persistentMultipleTabManager } from 'firebase/firestore'
2
3// Enable persistent cache for offline support
4const db = initializeFirestore(app, {
5 localCache: persistentLocalCache({
6 tabManager: persistentMultipleTabManager(),
7 }),
8})
9
10// Listener works offline — fires with cached data
11const unsubscribe = onSnapshot(
12 collection(db, 'tasks'),
13 (snapshot) => {
14 const fromCache = snapshot.metadata.fromCache
15 console.log(fromCache ? 'Offline data' : 'Server data')
16
17 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

use-firestore-listener.ts
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'
12
13const 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})
18
19const db = getFirestore(app)
20
21export 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)
25
26 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])
38
39 return { data, loading, error }
40}
41
42export 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)
49
50 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])
63
64 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.

ChatGPT Prompt

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.

Firebase Prompt

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.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Need help with your project?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.