DEV Community

Gabriel Sakala
Gabriel Sakala

Posted on

How to Build a Real-Time Chat System with Firebase Firestore and Vanilla JavaScript

How to Build a Real-Time Chat System with Firebase Firestore and Vanilla JavaScript

Real-time chat is one of those features that sounds complex but becomes surprisingly manageable once you understand how Firebase Firestore's live listeners work. No WebSockets to configure, no server to maintain โ€” Firestore handles the real-time layer for you.

In this tutorial, I'll walk you through building a fully functional chat system using Firebase Firestore and vanilla JavaScript. This is the exact architecture behind the chat feature in MySocial, a social media platform I built from scratch, which includes real-time messaging, reply-to-message functionality, and emoji reactions.

By the end of this tutorial, you'll have built:

  • A real-time chat UI that updates instantly for all users
  • Message sending with Firebase Auth user identity
  • A reply-to-message system
  • Emoji reactions on messages
  • Auto-scroll to the latest message

Prerequisites

  • A Firebase project with Firestore and Authentication enabled
  • Basic knowledge of JavaScript and Firestore
  • Firebase initialized in your project (see the Firebase docs)

Firestore Data Structure

Before writing any code, let's plan the data structure. A clean structure makes querying and rendering much easier.

We'll use two collections:

/chats/{chatId}/
  participants: [uid1, uid2]
  lastMessage: "Hey!"
  lastMessageTime: timestamp

/chats/{chatId}/messages/{messageId}/
  text: "Hey!"
  senderId: "uid123"
  senderName: "Gabriel"
  timestamp: serverTimestamp()
  replyTo: {              // optional
    messageId: "abc123"
    text: "Original message"
    senderName: "Alice"
  }
  reactions: {            // optional
    "โค๏ธ": ["uid1", "uid2"],
    "๐Ÿ˜‚": ["uid3"]
  }
Enter fullscreen mode Exit fullscreen mode

Each chat document holds metadata, and messages live in a subcollection. This keeps queries fast and costs low.


Setting Up Firestore Security Rules

Before anything else, set up rules so users can only read and write their own chats:

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /chats/{chatId} {
      allow read, write: if request.auth != null &&
        request.auth.uid in resource.data.participants;

      match /messages/{messageId} {
        allow read: if request.auth != null &&
          request.auth.uid in get(/databases/$(database)/documents/chats/$(chatId)).data.participants;
        allow create: if request.auth != null &&
          request.auth.uid in get(/databases/$(database)/documents/chats/$(chatId)).data.participants;
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Project Structure

/chat-app
  index.html
  chat.js
  firebase-config.js
  styles.css
Enter fullscreen mode Exit fullscreen mode

Firebase Config

// firebase-config.js
import { initializeApp } from "https://www.gstatic.com/firebasejs/10.7.0/firebase-app.js";
import { getFirestore } from "https://www.gstatic.com/firebasejs/10.7.0/firebase-firestore.js";
import { getAuth } from "https://www.gstatic.com/firebasejs/10.7.0/firebase-auth.js";

const firebaseConfig = {
  apiKey: "YOUR_API_KEY",
  authDomain: "YOUR_AUTH_DOMAIN",
  projectId: "YOUR_PROJECT_ID",
};

const app = initializeApp(firebaseConfig);
export const db = getFirestore(app);
export const auth = getAuth(app);
Enter fullscreen mode Exit fullscreen mode

Building the Chat UI

<!-- index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
  <title>Chat</title>
  <link rel="stylesheet" href="styles.css" />
</head>
<body>
  <div id="chat-container">
    <div id="chat-header">
      <span id="chat-with">Chat</span>
    </div>

    <!-- Reply preview banner -->
    <div id="reply-banner" style="display:none;">
      <span id="reply-preview-text"></span>
      <button id="cancel-reply">โœ•</button>
    </div>

    <!-- Messages list -->
    <div id="messages-list"></div>

    <!-- Input area -->
    <div id="chat-input-area">
      <input type="text" id="message-input" placeholder="Type a message..." autocomplete="off" />
      <button id="send-btn">Send</button>
    </div>
  </div>

  <script type="module" src="chat.js"></script>
</body>
</html>
Enter fullscreen mode Exit fullscreen mode

Basic Styles

/* styles.css */
* { box-sizing: border-box; margin: 0; padding: 0; }

body {
  font-family: sans-serif;
  background: #f0f2f5;
  display: flex;
  justify-content: center;
  align-items: center;
  height: 100vh;
}

#chat-container {
  width: 100%;
  max-width: 600px;
  height: 90vh;
  background: #fff;
  border-radius: 12px;
  display: flex;
  flex-direction: column;
  overflow: hidden;
  box-shadow: 0 4px 20px rgba(0,0,0,0.1);
}

#chat-header {
  padding: 16px;
  background: #1a73e8;
  color: white;
  font-weight: bold;
  font-size: 16px;
}

#messages-list {
  flex: 1;
  overflow-y: auto;
  padding: 16px;
  display: flex;
  flex-direction: column;
  gap: 8px;
}

.message-bubble {
  max-width: 75%;
  padding: 10px 14px;
  border-radius: 18px;
  font-size: 14px;
  line-height: 1.4;
  position: relative;
  word-wrap: break-word;
}

.message-bubble.sent {
  background: #1a73e8;
  color: white;
  align-self: flex-end;
  border-bottom-right-radius: 4px;
}

.message-bubble.received {
  background: #e8eaed;
  color: #202124;
  align-self: flex-start;
  border-bottom-left-radius: 4px;
}

.reply-quote {
  background: rgba(0,0,0,0.1);
  border-left: 3px solid rgba(255,255,255,0.6);
  border-radius: 4px;
  padding: 4px 8px;
  margin-bottom: 6px;
  font-size: 12px;
  opacity: 0.85;
}

.reactions-row {
  display: flex;
  gap: 4px;
  margin-top: 4px;
  flex-wrap: wrap;
}

.reaction-pill {
  background: rgba(0,0,0,0.08);
  border-radius: 12px;
  padding: 2px 8px;
  font-size: 13px;
  cursor: pointer;
  user-select: none;
}

.reaction-pill:hover { background: rgba(0,0,0,0.15); }

#reply-banner {
  background: #e8f0fe;
  padding: 8px 16px;
  font-size: 13px;
  color: #1a73e8;
  display: flex;
  justify-content: space-between;
  align-items: center;
}

#cancel-reply {
  background: none;
  border: none;
  cursor: pointer;
  font-size: 16px;
  color: #888;
}

#chat-input-area {
  display: flex;
  padding: 12px 16px;
  gap: 8px;
  border-top: 1px solid #e0e0e0;
}

#message-input {
  flex: 1;
  padding: 10px 14px;
  border: 1px solid #e0e0e0;
  border-radius: 24px;
  outline: none;
  font-size: 14px;
}

#message-input:focus { border-color: #1a73e8; }

#send-btn {
  padding: 10px 20px;
  background: #1a73e8;
  color: white;
  border: none;
  border-radius: 24px;
  cursor: pointer;
  font-size: 14px;
}
Enter fullscreen mode Exit fullscreen mode

Core Chat Logic

Now for chat.js โ€” this is where everything comes together.

Imports and State

import { db, auth } from "./firebase-config.js";
import {
  collection, addDoc, onSnapshot, query,
  orderBy, serverTimestamp, doc, updateDoc
} from "https://www.gstatic.com/firebasejs/10.7.0/firebase-firestore.js";
import { onAuthStateChanged } from "https://www.gstatic.com/firebasejs/10.7.0/firebase-auth.js";

// State
let currentUser = null;
let chatId = null;        // Set this to your actual chat document ID
let replyingTo = null;    // Holds message being replied to

const messagesList = document.getElementById("messages-list");
const messageInput = document.getElementById("message-input");
const sendBtn = document.getElementById("send-btn");
const replyBanner = document.getElementById("reply-banner");
const replyPreviewText = document.getElementById("reply-preview-text");
const cancelReplyBtn = document.getElementById("cancel-reply");

// For demo purposes โ€” in production, get this from your routing/URL
chatId = "your_chat_id_here";
Enter fullscreen mode Exit fullscreen mode

Auth State

onAuthStateChanged(auth, (user) => {
  if (user) {
    currentUser = user;
    listenToMessages();
  } else {
    // Redirect to login
    window.location.href = "/login.html";
  }
});
Enter fullscreen mode Exit fullscreen mode

Listening to Messages in Real Time

This is the heart of the chat. Firestore's onSnapshot fires every time the messages subcollection changes โ€” new messages appear instantly without any polling.

function listenToMessages() {
  const messagesRef = collection(db, "chats", chatId, "messages");
  const q = query(messagesRef, orderBy("timestamp", "asc"));

  onSnapshot(q, (snapshot) => {
    // On first load, render all messages
    // On subsequent updates, only new messages are added
    snapshot.docChanges().forEach((change) => {
      if (change.type === "added") {
        renderMessage(change.doc.id, change.doc.data());
      }

      if (change.type === "modified") {
        // Update existing message (e.g. new reaction)
        updateMessageElement(change.doc.id, change.doc.data());
      }
    });

    scrollToBottom();
  });
}
Enter fullscreen mode Exit fullscreen mode

Using docChanges() instead of looping through all documents is important โ€” it means we only process what actually changed, not the entire message history every time.


Rendering Messages

function renderMessage(messageId, data) {
  const isSent = data.senderId === currentUser.uid;
  const wrapper = document.createElement("div");
  wrapper.dataset.messageId = messageId;
  wrapper.style.display = "flex";
  wrapper.style.flexDirection = "column";
  wrapper.style.alignItems = isSent ? "flex-end" : "flex-start";

  const bubble = document.createElement("div");
  bubble.className = `message-bubble ${isSent ? "sent" : "received"}`;

  // Reply quote
  if (data.replyTo) {
    const quote = document.createElement("div");
    quote.className = "reply-quote";
    quote.textContent = `${data.replyTo.senderName}: ${data.replyTo.text}`;
    bubble.appendChild(quote);
  }

  // Message text
  const text = document.createElement("p");
  text.textContent = data.text;
  bubble.appendChild(text);

  // Sender name (for received messages)
  if (!isSent) {
    const name = document.createElement("small");
    name.style.cssText = "font-size:11px;color:#888;margin-bottom:2px;";
    name.textContent = data.senderName;
    wrapper.appendChild(name);
  }

  // Reactions row
  const reactionsRow = document.createElement("div");
  reactionsRow.className = "reactions-row";
  reactionsRow.dataset.messageId = messageId;
  renderReactions(reactionsRow, messageId, data.reactions || {});

  // Long press to reply (mobile-friendly)
  let pressTimer;
  bubble.addEventListener("touchstart", () => {
    pressTimer = setTimeout(() => showReplyOption(messageId, data), 500);
  });
  bubble.addEventListener("touchend", () => clearTimeout(pressTimer));

  // Right-click to reply (desktop)
  bubble.addEventListener("contextmenu", (e) => {
    e.preventDefault();
    showReplyOption(messageId, data);
  });

  wrapper.appendChild(bubble);
  wrapper.appendChild(reactionsRow);
  messagesList.appendChild(wrapper);
}
Enter fullscreen mode Exit fullscreen mode

Sending Messages

sendBtn.addEventListener("click", sendMessage);
messageInput.addEventListener("keydown", (e) => {
  if (e.key === "Enter" && !e.shiftKey) {
    e.preventDefault();
    sendMessage();
  }
});

async function sendMessage() {
  const text = messageInput.value.trim();
  if (!text || !currentUser) return;

  messageInput.value = "";

  const messageData = {
    text,
    senderId: currentUser.uid,
    senderName: currentUser.displayName || "User",
    timestamp: serverTimestamp(),
    reactions: {}
  };

  // Attach reply data if replying
  if (replyingTo) {
    messageData.replyTo = {
      messageId: replyingTo.id,
      text: replyingTo.text,
      senderName: replyingTo.senderName
    };
    clearReply();
  }

  try {
    await addDoc(collection(db, "chats", chatId, "messages"), messageData);

    // Update last message on the chat document
    await updateDoc(doc(db, "chats", chatId), {
      lastMessage: text,
      lastMessageTime: serverTimestamp()
    });
  } catch (error) {
    console.error("Failed to send message:", error);
  }
}
Enter fullscreen mode Exit fullscreen mode

Reply-to-Message System

function showReplyOption(messageId, data) {
  replyingTo = { id: messageId, text: data.text, senderName: data.senderName };
  replyPreviewText.textContent = `Replying to ${data.senderName}: "${data.text.substring(0, 40)}${data.text.length > 40 ? "..." : ""}"`;
  replyBanner.style.display = "flex";
  messageInput.focus();
}

cancelReplyBtn.addEventListener("click", clearReply);

function clearReply() {
  replyingTo = null;
  replyBanner.style.display = "none";
  replyPreviewText.textContent = "";
}
Enter fullscreen mode Exit fullscreen mode

When a user long-presses (mobile) or right-clicks (desktop) a message, showReplyOption is called. The reply banner appears showing a preview of the message being replied to. When the next message is sent, the replyTo object is attached to it.


Emoji Reactions

Reactions are stored as a map on each message document โ€” the key is the emoji, and the value is an array of user IDs who reacted with it. This prevents duplicate reactions and makes it easy to toggle.

const EMOJI_OPTIONS = ["โค๏ธ", "๐Ÿ˜‚", "๐Ÿ˜ฎ", "๐Ÿ‘", "๐Ÿ”ฅ"];

function renderReactions(container, messageId, reactions) {
  container.innerHTML = "";

  // Render existing reactions
  Object.entries(reactions).forEach(([emoji, uids]) => {
    if (uids.length === 0) return;
    const pill = document.createElement("span");
    pill.className = "reaction-pill";
    pill.textContent = `${emoji} ${uids.length}`;
    pill.addEventListener("click", () => toggleReaction(messageId, emoji, reactions));
    container.appendChild(pill);
  });

  // Add reaction button
  const addBtn = document.createElement("span");
  addBtn.className = "reaction-pill";
  addBtn.textContent = "๏ผ‹";
  addBtn.style.opacity = "0.5";
  addBtn.addEventListener("click", () => showEmojiPicker(messageId, reactions, container));
  container.appendChild(addBtn);
}

function showEmojiPicker(messageId, reactions, container) {
  // Remove existing picker if open
  document.querySelectorAll(".emoji-picker").forEach(p => p.remove());

  const picker = document.createElement("div");
  picker.className = "emoji-picker";
  picker.style.cssText = `
    position: absolute; background: white; border-radius: 24px;
    box-shadow: 0 4px 16px rgba(0,0,0,0.15); padding: 6px 10px;
    display: flex; gap: 8px; z-index: 100; font-size: 20px;
  `;

  EMOJI_OPTIONS.forEach((emoji) => {
    const btn = document.createElement("span");
    btn.textContent = emoji;
    btn.style.cursor = "pointer";
    btn.addEventListener("click", () => {
      toggleReaction(messageId, emoji, reactions);
      picker.remove();
    });
    picker.appendChild(btn);
  });

  container.style.position = "relative";
  container.appendChild(picker);

  // Close picker on outside click
  setTimeout(() => {
    document.addEventListener("click", () => picker.remove(), { once: true });
  }, 0);
}

async function toggleReaction(messageId, emoji, currentReactions) {
  const uid = currentUser.uid;
  const updatedReactions = { ...currentReactions };

  if (!updatedReactions[emoji]) {
    updatedReactions[emoji] = [];
  }

  const index = updatedReactions[emoji].indexOf(uid);
  if (index === -1) {
    // Add reaction
    updatedReactions[emoji] = [...updatedReactions[emoji], uid];
  } else {
    // Remove reaction (toggle off)
    updatedReactions[emoji] = updatedReactions[emoji].filter(id => id !== uid);
  }

  const messageRef = doc(db, "chats", chatId, "messages", messageId);
  await updateDoc(messageRef, { reactions: updatedReactions });
}
Enter fullscreen mode Exit fullscreen mode

Updating Messages in Place

When a reaction is added or removed, Firestore triggers the modified event in our onSnapshot listener. We update just the reactions row rather than re-rendering the whole message:

function updateMessageElement(messageId, data) {
  const reactionsRow = document.querySelector(`.reactions-row[data-message-id="${messageId}"]`);
  if (reactionsRow) {
    renderReactions(reactionsRow, messageId, data.reactions || {});
  }
}
Enter fullscreen mode Exit fullscreen mode

Auto-Scroll

function scrollToBottom() {
  messagesList.scrollTop = messagesList.scrollHeight;
}
Enter fullscreen mode Exit fullscreen mode

This is called after every new message renders, keeping the view pinned to the latest message โ€” the standard behavior users expect from any chat app.


Performance Considerations

Paginate old messages: Loading thousands of messages at once is expensive. In production, use limit() to load the last 30โ€“50 messages and add a "Load more" button for history:

import { limit } from "https://www.gstatic.com/firebasejs/10.7.0/firebase-firestore.js";

const q = query(messagesRef, orderBy("timestamp", "asc"), limit(50));
Enter fullscreen mode Exit fullscreen mode

Detach listeners on page leave: Always unsubscribe from onSnapshot when the user navigates away to avoid memory leaks:

const unsubscribe = onSnapshot(q, (snapshot) => { /* ... */ });

window.addEventListener("beforeunload", () => unsubscribe());
Enter fullscreen mode Exit fullscreen mode

Index your queries: If Firestore warns about missing indexes in the console, follow the link it provides to create them. Queries with orderBy on subcollections often need a composite index.


Conclusion

With Firestore's onSnapshot, building a real-time chat system doesn't require WebSockets, a custom server, or any complex infrastructure. The key concepts covered in this tutorial are:

  • Using subcollections to structure chat data cleanly
  • Listening to docChanges() for efficient real-time updates
  • Storing replies as embedded objects on each message
  • Using a reactions map with user ID arrays for toggle behavior
  • Updating message elements in place instead of re-rendering

This is the same architecture powering the chat in MySocial, and it handles real users and real-time updates reliably without any backend beyond Firestore.

From here, you can extend the system with:

  • Typing indicators using a typing field on the chat document
  • Read receipts by storing readBy: [uid1, uid2] on each message
  • Media messages by combining this system with Cloudinary uploads (see my previous tutorial)
  • Push notifications via Firebase Cloud Messaging

Top comments (1)

Collapse
 
prajituric profile image
Bugheanu Danut Andrei

The real-time listener is the easy part. The part that quietly becomes a headache is everything users attach to the chat.

Once people can send avatars, screenshots, and image messages, you suddenly care about upload speed, thumbnail generation, format conversion, and not shipping 4 MB images into a chat feed. Thatโ€™s usually where people end up building a second mini-system just for media.

A cleaner pattern is to keep chat data in Firestore and push media through a dedicated image pipeline so uploads are fast, thumbnails are generated automatically, and the app only ever loads the size it actually needs. That way the chat logic and the media logic donโ€™t get welded together into one giant mess.

If you want, I can also sketch the simplest architecture for handling image messages without making the frontend do all the work.