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.
