Build a real-time chat with Firestore

Live updates in about 40 lines of JavaScript - plus the security rules most tutorials skip.

Real-time features usually mean running a WebSocket server. With Firestore, the browser subscribes to a query and Google's servers push every change to every connected client. This guide builds a public chat room with plain JavaScript and Vite, so nothing gets between you and the Firebase APIs - then locks it down properly.

You'll need: Node.js (current LTS), a Google account, and about 45 minutes. The free Spark plan covers everything here.

1. Create the Firebase project

  1. In the Firebase console, create a new project (you can skip Google Analytics).
  2. Under Build → Firestore Database, create a database in production mode and pick a location near your users. Production mode starts locked - you'll write proper rules in step 5.
  3. In Project settings, add a Web app. Firebase shows a firebaseConfig object - copy it.
The config object is not a secret. It only identifies your project, and it ends up in every visitor's browser anyway. What protects your data is security rules.

2. Set up the project

npm create vite@latest firestore-chat -- --template vanilla
cd firestore-chat
npm install
npm install firebase

3. The page

Replace index.html:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Firestore chat</title>
  </head>
  <body>
    <h1>Chat</h1>
    <ul id="messages"></ul>
    <form id="form">
      <input id="text" maxlength="500" autocomplete="off" placeholder="Say something" required />
      <button>Send</button>
    </form>
    <script type="module" src="/src/main.js"></script>
  </body>
</html>

4. The JavaScript

Replace src/main.js, pasting your own config at the top:

import { initializeApp } from "firebase/app";
import {
  getFirestore,
  collection,
  addDoc,
  query,
  orderBy,
  limit,
  onSnapshot,
  serverTimestamp,
} from "firebase/firestore";

const firebaseConfig = {
  // paste the object from the Firebase console here
};

const app = initializeApp(firebaseConfig);
const db = getFirestore(app);
const messages = collection(db, "messages");

const list = document.querySelector("#messages");
const form = document.querySelector("#form");
const input = document.querySelector("#text");

// Newest 50 messages, re-delivered whenever anything changes
const recent = query(messages, orderBy("createdAt", "desc"), limit(50));

onSnapshot(recent, (snapshot) => {
  list.innerHTML = "";
  [...snapshot.docs].reverse().forEach((doc) => {
    const li = document.createElement("li");
    li.textContent = doc.data().text; // textContent, never innerHTML, for user text
    list.appendChild(li);
  });
});

form.addEventListener("submit", async (e) => {
  e.preventDefault();
  const text = input.value.trim();
  if (!text) return;
  input.value = "";
  await addDoc(messages, { text, createdAt: serverTimestamp() });
});

Three details matter here. The query is limited - without limit(), every visitor would download every message ever sent, and you pay per document read. Timestamps come from serverTimestamp(), so a visitor with a wrong clock can't reorder the chat. And messages are inserted with textContent, so nobody can inject HTML or scripts into other people's screens.


5. Security rules

Right now production mode denies everything, so the chat won't work yet. In the console, open Firestore → Rules and replace them with:

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /messages/{messageId} {
      allow read: if true;
      allow create: if request.resource.data.keys().hasOnly(['text', 'createdAt'])
                    && request.resource.data.text is string
                    && request.resource.data.text.size() > 0
                    && request.resource.data.text.size() <= 500
                    && request.resource.data.createdAt == request.time;
      allow update, delete: if false;
    }
  }
}

These rules let anyone read messages and post new ones, but only with exactly two fields, a non-empty string of at most 500 characters, and a genuine server timestamp. Nobody can edit or delete messages, and every other collection stays locked. Click Publish.

Avoid "test mode" rules, which allow anyone to read and write your entire database until they expire. It's the most common way Firebase projects get abused.

6. Try it

npm run dev

Open the page in two browser windows side by side and send a message from one - it appears in both almost instantly. That's the whole real-time system: no server code, no WebSockets, no polling.


7. Deploy

Build the site with npm run build, then either:

  • Firebase Hosting: install the CLI (npm install -g firebase-tools), run firebase login and firebase init hosting (choose dist as the public directory), then firebase deploy.
  • Any static host or shared hosting: upload the contents of dist. The real-time part runs on Google's servers, so your host only serves files.

Costs to keep in mind

On the free Spark plan, Firestore includes a daily allowance of document reads, writes and deletes (at the time of writing, 50,000 reads and 20,000 writes per day). Each message delivered to each open listener counts as a read, so a busy room with many viewers uses reads quickly. Keep queries limited, and if you upgrade to the pay-as-you-go Blaze plan, set a budget alert.


Next steps

  • Identity: add Firebase Authentication (anonymous or Google sign-in), store request.auth.uid on each message, and require it in the rules.
  • Moderation: let users delete their own messages with a rule comparing the stored uid to request.auth.uid.
  • React: move the listener into a hook - the React + Node.js + Firebase pick shows how.
All guides Firebase primer