# How to Listen to Real-Time Updates in Firestore

- Tool: Firebase
- Difficulty: Intermediate
- Time required: 12-18 min
- Compatibility: Firebase JS SDK v9+, all plans
- Last updated: March 2026

## 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.

## Before you start

- 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.

```
import { getFirestore, doc, onSnapshot } from 'firebase/firestore'

const db = getFirestore()

const unsubscribe = onSnapshot(
  doc(db, 'users', 'user-123'),
  (snapshot) => {
    if (snapshot.exists()) {
      const data = snapshot.data()
      console.log('User data:', data)
      console.log('Document ID:', snapshot.id)
    } else {
      console.log('Document does not exist')
    }
  },
  (error) => {
    console.error('Listener error:', error.message)
  }
)

// 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.

```
import { collection, query, where, orderBy, limit, onSnapshot } from 'firebase/firestore'

const q = query(
  collection(db, 'messages'),
  where('roomId', '==', 'room-abc'),
  orderBy('createdAt', 'desc'),
  limit(50)
)

const unsubscribe = onSnapshot(q, (snapshot) => {
  // Full list of matching documents
  const messages = snapshot.docs.map((doc) => ({
    id: doc.id,
    ...doc.data(),
  }))

  // Only the changes since last snapshot
  snapshot.docChanges().forEach((change) => {
    if (change.type === 'added') console.log('New:', change.doc.data())
    if (change.type === 'modified') console.log('Updated:', change.doc.data())
    if (change.type === 'removed') console.log('Removed:', change.doc.id)
  })
})
```

**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.

```
const unsubscribe = onSnapshot(
  doc(db, 'posts', 'post-1'),
  { includeMetadataChanges: true },
  (snapshot) => {
    const source = snapshot.metadata.fromCache ? 'cache' : 'server'
    const pending = snapshot.metadata.hasPendingWrites

    console.log(`Data from ${source}, pending writes: ${pending}`)

    if (pending) {
      // Show "Saving..." indicator
    } else {
      // Data confirmed by server
    }

    const data = snapshot.data()
    console.log(data)
  }
)
```

**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.

```
import { useState, useEffect } from 'react'
import { doc, onSnapshot } from 'firebase/firestore'

function UserProfile({ userId }: { userId: string }) {
  const [profile, setProfile] = useState<any>(null)
  const [loading, setLoading] = useState(true)

  useEffect(() => {
    const unsubscribe = onSnapshot(
      doc(db, 'users', userId),
      (snapshot) => {
        setProfile(snapshot.exists() ? { id: snapshot.id, ...snapshot.data() } : null)
        setLoading(false)
      },
      (error) => {
        console.error('Listener error:', error)
        setLoading(false)
      }
    )

    // Cleanup: unsubscribe when component unmounts or userId changes
    return () => unsubscribe()
  }, [userId])

  if (loading) return <div>Loading...</div>
  if (!profile) return <div>User not found</div>
  return <div>{profile.displayName}</div>
}
```

**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.

```
import { initializeFirestore, persistentLocalCache, persistentMultipleTabManager } from 'firebase/firestore'

// Enable persistent cache for offline support
const db = initializeFirestore(app, {
  localCache: persistentLocalCache({
    tabManager: persistentMultipleTabManager(),
  }),
})

// Listener works offline — fires with cached data
const unsubscribe = onSnapshot(
  collection(db, 'tasks'),
  (snapshot) => {
    const fromCache = snapshot.metadata.fromCache
    console.log(fromCache ? 'Offline data' : 'Server data')

    const tasks = snapshot.docs.map((d) => ({ id: d.id, ...d.data() }))
    console.log(tasks)
  }
)
```

**Expected result:** Listeners fire with cached data when offline and sync automatically when the connection is restored.

## Complete code example

File: `use-firestore-listener.ts`

```typescript
import { useState, useEffect } from 'react'
import { initializeApp } from 'firebase/app'
import {
  getFirestore,
  doc,
  collection,
  query,
  onSnapshot,
  QueryConstraint,
  DocumentData,
} from 'firebase/firestore'

const app = initializeApp({
  apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY!,
  authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN!,
  projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID!,
})

const db = getFirestore(app)

export function useDocument<T = DocumentData>(path: string, id: string) {
  const [data, setData] = useState<(T & { id: string }) | null>(null)
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState<Error | null>(null)

  useEffect(() => {
    setLoading(true)
    const unsubscribe = onSnapshot(
      doc(db, path, id),
      (snap) => {
        setData(snap.exists() ? { id: snap.id, ...(snap.data() as T) } : null)
        setLoading(false)
      },
      (err) => { setError(err); setLoading(false) }
    )
    return () => unsubscribe()
  }, [path, id])

  return { data, loading, error }
}

export function useCollection<T = DocumentData>(
  path: string,
  ...constraints: QueryConstraint[]
) {
  const [data, setData] = useState<(T & { id: string })[]>([])
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState<Error | null>(null)

  useEffect(() => {
    setLoading(true)
    const q = query(collection(db, path), ...constraints)
    const unsubscribe = onSnapshot(
      q,
      (snap) => {
        setData(snap.docs.map((d) => ({ id: d.id, ...(d.data() as T) })))
        setLoading(false)
      },
      (err) => { setError(err); setLoading(false) }
    )
    return () => unsubscribe()
  }, [path, ...constraints])

  return { data, loading, error }
}
```

## Common mistakes

- **Not unsubscribing from listeners when a component unmounts, causing memory leaks and phantom reads** — undefined Fix: Always return the unsubscribe function from useEffect cleanup. Store it in a ref if you need to unsubscribe outside the effect.
- **Attaching a new listener on every render because the query object is recreated each time** — undefined Fix: Memoize query constraints with useMemo or define the query outside the component to avoid infinite listener attach/detach cycles.
- **Ignoring the error callback, causing listeners to silently fail on permission denied errors** — undefined Fix: Always pass an error callback as the third argument to onSnapshot. Log the error and show a user-friendly message.
- **Using onSnapshot when a one-time read with getDoc would suffice, wasting reads on data that rarely changes** — undefined Fix: 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

## 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.

---

Source: https://www.rapidevelopers.com/firebase-tutorial/how-to-listen-to-real-time-updates-in-firestore
© RapidDev — https://www.rapidevelopers.com/firebase-tutorial/how-to-listen-to-real-time-updates-in-firestore
