# How to Unsubscribe from a Firestore Snapshot Listener

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

## TL;DR

To unsubscribe from a Firestore snapshot listener, store the return value of onSnapshot() in a variable and call it as a function when you no longer need updates. In React, return the unsubscribe function from a useEffect cleanup. Failing to unsubscribe causes memory leaks, stale UI updates, and unnecessary Firestore read charges that accumulate over time.

## Unsubscribing from Firestore Snapshot Listeners

Firestore's onSnapshot() creates a persistent connection that streams document changes to your app in real time. Every listener keeps a WebSocket open and counts reads against your Firestore quota. If you navigate away from a page or unmount a component without unsubscribing, the listener stays active in the background, wasting resources and potentially causing errors when it tries to update unmounted components. This tutorial shows you how to properly unsubscribe in plain JavaScript and in React.

## Before you start

- A Firebase project with Firestore enabled
- Firebase JS SDK v9+ installed in your project
- Basic understanding of JavaScript Promises and closures
- Familiarity with React hooks if using the React examples

## Step-by-step guide

### 1. Understand the onSnapshot return value

When you call onSnapshot() on a document or query reference, it returns an Unsubscribe function. This function takes no arguments and stops the listener when called. The key insight is that onSnapshot() does not return a Promise or data — it returns the cleanup function directly.

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

// onSnapshot returns an unsubscribe function
const unsubscribe = onSnapshot(
  doc(db, 'users', 'user123'),
  (snapshot) => {
    console.log('Current data:', snapshot.data());
  }
);

// Later, when you no longer need updates:
unsubscribe();
```

**Expected result:** The listener is active and streaming updates until unsubscribe() is called.

### 2. Unsubscribe in a React useEffect cleanup

In React, the standard pattern is to start the listener inside useEffect and return the unsubscribe function as the cleanup. React calls the cleanup function when the component unmounts or when the dependency array changes. This prevents memory leaks and avoids the 'Can't perform a React state update on an unmounted component' warning.

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

interface UserProfile {
  name: string;
  email: string;
}

export function useUserProfile(userId: string) {
  const [profile, setProfile] = useState<UserProfile | null>(null);
  const [loading, setLoading] = useState(true);

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

    // React calls this when component unmounts or userId changes
    return unsubscribe;
  }, [userId]);

  return { profile, loading };
}
```

**Expected result:** The listener starts when the component mounts and stops automatically when it unmounts or when userId changes.

### 3. Unsubscribe from a query listener

Query listeners work the same way as document listeners. Call onSnapshot() on a Query object and store the returned function. The snapshot in the callback is a QuerySnapshot with a docs array. Each re-render of the query results counts as reads for every document in the result set, so unsubscribing promptly is especially important for large collections.

```
import { useEffect, useState } from 'react';
import { collection, query, where, orderBy, onSnapshot } from 'firebase/firestore';
import { db } from './firebase';

interface Task {
  id: string;
  title: string;
  completed: boolean;
}

export function useTasks(userId: string) {
  const [tasks, setTasks] = useState<Task[]>([]);

  useEffect(() => {
    const q = query(
      collection(db, 'tasks'),
      where('ownerId', '==', userId),
      orderBy('createdAt', 'desc')
    );

    const unsubscribe = onSnapshot(q, (snapshot) => {
      const items = snapshot.docs.map((doc) => ({
        id: doc.id,
        ...doc.data(),
      })) as Task[];
      setTasks(items);
    });

    return unsubscribe;
  }, [userId]);

  return tasks;
}
```

**Expected result:** The query listener streams task updates in real time and stops when the component unmounts.

### 4. Manage multiple listeners with a cleanup array

When a component subscribes to several Firestore paths, track all unsubscribe functions in an array and call them all during cleanup. This pattern prevents partial cleanup where some listeners keep running after the component unmounts.

```
import { useEffect } from 'react';
import { doc, collection, onSnapshot } from 'firebase/firestore';
import { db } from './firebase';

export function useDashboard(userId: string) {
  useEffect(() => {
    const unsubscribes: (() => void)[] = [];

    // Listener 1: user profile
    unsubscribes.push(
      onSnapshot(doc(db, 'users', userId), (snap) => {
        // handle profile update
      })
    );

    // Listener 2: notifications
    unsubscribes.push(
      onSnapshot(collection(db, 'users', userId, 'notifications'), (snap) => {
        // handle notifications update
      })
    );

    // Cleanup: unsubscribe from all listeners
    return () => {
      unsubscribes.forEach((unsub) => unsub());
    };
  }, [userId]);
}
```

**Expected result:** All listeners are properly cleaned up when the component unmounts, preventing memory leaks.

### 5. Handle errors in the listener callback

The third argument to onSnapshot() is an error callback. If a security rule changes or the user loses authentication while a listener is active, Firestore triggers this callback. Always handle errors to prevent silent failures and to clean up the listener gracefully.

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

const unsubscribe = onSnapshot(
  doc(db, 'orders', 'order123'),
  (snapshot) => {
    console.log('Order data:', snapshot.data());
  },
  (error: FirestoreError) => {
    console.error('Listener failed:', error.code, error.message);
    // Common: 'permission-denied' when auth state changes
    if (error.code === 'permission-denied') {
      unsubscribe(); // Stop the broken listener
    }
  }
);
```

**Expected result:** Errors are caught and logged instead of failing silently, and broken listeners are cleaned up.

## Complete code example

File: `useFirestoreListener.ts`

```typescript
import { useEffect, useState } from 'react';
import {
  doc,
  collection,
  query,
  where,
  orderBy,
  onSnapshot,
  DocumentData,
  QueryConstraint,
} from 'firebase/firestore';
import { db } from './firebase';

// Generic hook for a single document listener
export function useDocument<T = DocumentData>(
  path: string,
  id: string
) {
  const [data, setData] = useState<T | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

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

  return { data, loading, error };
}

// Generic hook for a collection query listener
export function useCollection<T = DocumentData>(
  path: string,
  ...constraints: QueryConstraint[]
) {
  const [data, setData] = useState<(T & { id: string })[]>([]);
  const [loading, setLoading] = useState(true);

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

  return { data, loading };
}
```

## Common mistakes

- **Not storing the onSnapshot return value, making it impossible to unsubscribe** — undefined Fix: Always assign the return value to a variable: const unsubscribe = onSnapshot(...). Without it, the listener runs forever.
- **Calling onSnapshot inside useEffect without returning the unsubscribe function** — undefined Fix: Return the unsubscribe function directly from useEffect: return unsubscribe; or return () => unsubscribe();
- **Creating a new listener on every render by placing onSnapshot outside useEffect** — undefined Fix: Always place onSnapshot inside useEffect with proper dependencies. Each call creates a new WebSocket connection.

## Best practices

- Always return the unsubscribe function from React useEffect to prevent memory leaks on unmount
- Include all relevant dependencies (userId, documentId) in the useEffect dependency array
- Use the error callback in onSnapshot to handle permission changes and network failures gracefully
- Track multiple unsubscribe functions in an array when a component has several listeners
- Create reusable custom hooks (useDocument, useCollection) to standardize listener management
- Avoid listening to large collections without query constraints — each document counts as a read on every update
- Unsubscribe from listeners when the user signs out to prevent permission-denied errors

## Frequently asked questions

### What happens if I forget to unsubscribe from a Firestore listener?

The listener continues running in the background, consuming memory, using bandwidth, and counting Firestore reads against your quota. In React, it can also cause errors when trying to update state on unmounted components.

### Does unsubscribing from a listener cancel in-flight reads?

Calling unsubscribe stops future updates but does not cancel the current snapshot being processed. Any reads already charged are not refunded.

### Can I resubscribe after calling unsubscribe?

You cannot reuse the same unsubscribe function. To listen again, call onSnapshot() again to create a new listener and get a new unsubscribe function.

### Do Firestore listeners automatically stop when the browser tab closes?

Yes. When the browser tab or window closes, all WebSocket connections are terminated and listeners stop. However, navigating between pages in an SPA does not close the tab, so you must unsubscribe manually.

### How many active listeners can I have at once?

There is no hard limit from Firebase, but each listener uses a WebSocket connection and memory. In practice, keep active listeners under 100 per client to avoid performance issues.

### Can RapidDev help optimize my real-time Firestore architecture?

Yes. RapidDev can audit your Firestore listener patterns, reduce unnecessary reads, and implement efficient real-time data flows that scale without excessive costs.

---

Source: https://www.rapidevelopers.com/firebase-tutorial/how-to-unsubscribe-from-firestore-snapshot-listener
© RapidDev — https://www.rapidevelopers.com/firebase-tutorial/how-to-unsubscribe-from-firestore-snapshot-listener
