Firestore Real Time Listener

Real-time listeners watch a document or collection and push changes to your app the moment they happen. Unlike one-time reads that give you a snapshot at a single point in time, listeners stay active and deliver each new version of the data automatically. This powers live features like chat messages appearing instantly, dashboards updating without refresh, and collaborative editing.

Listener vs One-Time Read

One-Time Read (getDocs):
App asks: "Give me the posts right now"
Firestore sends: current posts
Connection ends

Real-Time Listener (onSnapshot):
App says: "Watch the posts and tell me every time something changes"
Firestore sends: current posts immediately
Firestore sends: updated posts whenever any change happens
Connection stays open until you unsubscribe

Listening to a Single Document

import { doc, onSnapshot } from "firebase/firestore";
import { db } from "./firebase";

const userRef = doc(db, "users", "uid_alice");

// Start listening
const unsubscribe = onSnapshot(userRef, (snapshot) => {
  if (snapshot.exists()) {
    const data = snapshot.data();
    console.log("User data updated:", data.name, data.age);
  } else {
    console.log("Document was deleted.");
  }
});

// Stop listening when done
// unsubscribe();

The callback fires immediately with the current data, then fires again every time the document changes. The onSnapshot call returns an unsubscribe function — call it when you no longer need live updates.

Listening to a Collection

import { collection, query, orderBy, onSnapshot } from "firebase/firestore";
import { db } from "./firebase";

const q = query(
  collection(db, "messages"),
  orderBy("sentAt", "asc")
);

const unsubscribe = onSnapshot(q, (snapshot) => {
  const messages = snapshot.docs.map((doc) => ({
    id: doc.id,
    ...doc.data()
  }));
  renderMessages(messages);
});

Each time a message is added, updated, or deleted, the callback fires with the complete updated list of matching documents.

Tracking Document Changes

The snapshot provides a docChanges() method that tells you exactly which documents changed and how — added, modified, or removed. This is efficient for updating only the affected parts of your UI instead of re-rendering everything.

const unsubscribe = onSnapshot(q, (snapshot) => {
  snapshot.docChanges().forEach((change) => {
    const data = change.doc.data();

    if (change.type === "added") {
      console.log("New message:", data.text);
      appendMessageToUI(change.doc.id, data);
    }
    if (change.type === "modified") {
      console.log("Message edited:", data.text);
      updateMessageInUI(change.doc.id, data);
    }
    if (change.type === "removed") {
      console.log("Message deleted:", change.doc.id);
      removeMessageFromUI(change.doc.id);
    }
  });
});

Building a Live Chat

A live chat uses a collection listener on a messages collection. Here is the structure:

Firestore structure:
chats/{chatId}/messages/{messageId}
{
  text: "Hello!",
  authorId: "uid_alice",
  authorName: "Alice",
  sentAt: Timestamp
}
// HTML
// <div id="messages"></div>
// <input id="msg-input" />
// <button id="send-btn">Send</button>

import {
  collection, query, orderBy, onSnapshot,
  addDoc, serverTimestamp
} from "firebase/firestore";
import { db, auth } from "./firebase";

const chatId = "global-chat";
const messagesRef = collection(db, "chats", chatId, "messages");
const q = query(messagesRef, orderBy("sentAt", "asc"));

// Listen for new messages
const unsubscribe = onSnapshot(q, (snapshot) => {
  const container = document.getElementById("messages");
  container.innerHTML = "";
  snapshot.forEach((doc) => {
    const msg = doc.data();
    const p = document.createElement("p");
    p.textContent = msg.authorName + ": " + msg.text;
    container.appendChild(p);
  });
});

// Send a new message
document.getElementById("send-btn").addEventListener("click", async () => {
  const text = document.getElementById("msg-input").value.trim();
  if (!text) return;

  await addDoc(messagesRef, {
    text,
    authorId: auth.currentUser.uid,
    authorName: auth.currentUser.displayName,
    sentAt: serverTimestamp()
  });

  document.getElementById("msg-input").value = "";
});

Handling Listener Errors

onSnapshot accepts an optional error handler as the third argument:

const unsubscribe = onSnapshot(
  userRef,
  (snapshot) => {
    // success
    console.log(snapshot.data());
  },
  (error) => {
    // error — usually a permissions issue
    console.error("Listener error:", error.message);
  }
);

Memory Leak Prevention

Every active listener keeps an open connection to Firestore. Unused listeners waste bandwidth and can cause memory leaks in React apps. Always call the unsubscribe function when the component or page using the listener is removed:

// React useEffect pattern
useEffect(() => {
  const q = query(collection(db, "messages"), orderBy("sentAt"));
  const unsubscribe = onSnapshot(q, (snapshot) => {
    const msgs = snapshot.docs.map((d) => ({ id: d.id, ...d.data() }));
    setMessages(msgs);
  });

  return () => unsubscribe(); // cleanup when component unmounts
}, []);

Listening to a Document That May Not Exist Yet

Listeners work on documents that do not exist yet. The callback fires with snapshot.exists() === false initially, then fires again when the document is created. This is useful for waiting on async server processes:

// Listen for a report document that a Cloud Function will create
const reportRef = doc(db, "reports", reportId);
const unsubscribe = onSnapshot(reportRef, (snap) => {
  if (snap.exists()) {
    console.log("Report ready:", snap.data());
    unsubscribe(); // stop listening once we have what we need
  } else {
    console.log("Waiting for report...");
  }
});

Key Takeaway

Real-time listeners with onSnapshot keep your UI in sync with Firestore automatically. Use docChanges() to update only the parts of your UI that changed rather than re-rendering everything. Always store and call the unsubscribe function when you no longer need a listener — especially in React components — to prevent memory leaks. Listeners work on documents that do not yet exist and fire when they are eventually created, making them useful for waiting on background processes.

Leave a Comment

Your email address will not be published. Required fields are marked *