Build a Real-Time Chat App with AI: WebSockets, Rooms, and Live Messaging

This is the project that breaks you into backend thinking. You'll build a chat app where messages appear instantly on every connected screen — no refresh, no polling, just a live connection. You'll learn WebSockets, real-time state, and how servers push data to clients. AI writes the code. You direct it and understand what it builds.

TL;DR

You'll build a real-time group chat app: users enter a name, join a room, and messages appear live for everyone connected. Stack: Node.js + Express (server), Socket.io (WebSocket layer), HTML/CSS/vanilla JavaScript (frontend). No React, no database (messages are in-memory). Time to working app: ~2 hours. Deploys free to Railway or Render.

Why This Project?

Your to-do app taught you how browsers manage state. This chat app teaches you something fundamentally different: how two browsers talk to each other through a server in real time. That's a new mental model entirely.

Chat apps are the canonical real-time project because they force you to understand concepts that show up everywhere in modern software:

  • WebSockets: Persistent two-way connections between a browser and a server — the technology behind live sports scores, trading dashboards, multiplayer games, and collaborative tools like Figma
  • Real-time state: What happens when multiple users change the same data at the same time? How do you keep everyone's screen in sync?
  • State management across clients: The server becomes the source of truth, broadcasting updates to everyone who needs them
  • Event-driven architecture: Code that responds to events (message sent, user joined, user left) instead of linear top-to-bottom execution
  • User identity: Even a simple username system teaches you how servers track who's who across a connection

Every real-time feature you'll ever build — live notifications, collaborative editing, live dashboards, multiplayer anything — uses the same WebSocket pattern you'll learn here.

What You'll Build

A multi-room group chat application called LiveChat:

  • Landing screen: enter your username and pick a room (General, Tech, Random)
  • Chat screen: message input, scrolling message list, list of who's currently online
  • Messages appear instantly on every connected browser — no refresh
  • System messages when users join or leave ("Alex joined #general")
  • Your messages appear on the right (blue), others on the left (gray)
  • The last 50 messages in each room are kept in memory and shown to new joiners
  • User count badge on each room name

What it intentionally doesn't have (keep it scoped): persistent message history, user accounts with passwords, private messages, or file uploads. Those are extensions you'll add after.

What You'll Learn

  • How WebSocket connections work — the full lifecycle from handshake to disconnect
  • How Socket.io's room system lets you broadcast to subsets of connected users
  • Why the server must be the source of truth in real-time apps (not the browser)
  • How to handle connection and disconnection events gracefully
  • The difference between emit, broadcast, and to(room).emit
  • How to keep a frontend UI in sync with server-side state
  • How to deploy a Node.js server (different from deploying a static site)

Before You Start: What You Need

  • Node.js installed (v18 or later — check with node --version)
  • An AI coding tool: Cursor, Claude Code, Windsurf, or similar
  • A terminal you're comfortable opening
  • A free Railway or Render account for deployment

You do not need to understand JavaScript deeply. You need to be able to read what AI generates and ask follow-up questions when something is unclear.

Step 1: Set Up the Project

AI Prompt

"Set up a Node.js project for a real-time chat app called LiveChat. Create the folder structure: server.js at the root, a public/ folder for frontend files (index.html, chat.html, css/styles.css, js/client.js). Initialize package.json with name 'livechat', and install these dependencies: express (web server), socket.io (WebSocket library), cors (cross-origin requests). Add a start script that runs 'node server.js'. Show me the exact terminal commands to run."

What AI Generates

AI will give you terminal commands like these. Run them in order:

mkdir livechat
cd livechat
npm init -y
npm install express socket.io cors
mkdir public public/css public/js

Then it will update package.json to add the start script:

{
  "name": "livechat",
  "version": "1.0.0",
  "scripts": {
    "start": "node server.js"
  },
  "dependencies": {
    "express": "^4.18.2",
    "socket.io": "^4.7.4",
    "cors": "^2.8.5"
  }
}

What Each Dependency Does

  • express: Creates the HTTP server that serves your HTML files and handles routes. Think of it as the "base layer" your WebSocket server sits on top of.
  • socket.io: The WebSocket library. It handles the real-time connection between your server and every browser. It wraps the raw WebSocket API in a friendlier event-based system.
  • cors: Allows your frontend (potentially served from a different domain during dev) to connect to your server. Without this, browsers block the connection.

Step 2: Build the Server

AI Prompt

"Write server.js for my LiveChat app. Requirements: (1) Use Express to serve static files from the public/ folder. (2) Attach Socket.io to the HTTP server. (3) Keep a JavaScript object called 'rooms' that tracks each room's messages (max 50) and connected users. Available rooms: 'general', 'tech', 'random'. (4) When a socket connects: listen for a 'join' event containing { username, room }. Add the user to the room, send them the last 50 messages, broadcast a 'user-joined' system message to the room, emit an updated 'room-users' list. (5) Listen for 'send-message' events: save the message to the room's history, broadcast it to everyone in the room with { username, text, timestamp, id }. (6) On disconnect: remove the user from their room, broadcast a 'user-left' system message, emit an updated 'room-users' list. (7) Listen on port 3000. Include clear comments explaining each Socket.io event."

The Server Code (What AI Generates)

// server.js
const express = require('express')
const http = require('http')
const { Server } = require('socket.io')
const cors = require('cors')

const app = express()
const server = http.createServer(app)

// Attach Socket.io to the HTTP server
const io = new Server(server, {
  cors: { origin: '*' }
})

app.use(cors())
app.use(express.static('public'))  // Serve HTML/CSS/JS from public/

// ===== IN-MEMORY STATE =====
// Each room has a list of messages and a map of socketId -> username
const rooms = {
  general: { messages: [], users: {} },
  tech:    { messages: [], users: {} },
  random:  { messages: [], users: {} }
}

// ===== SOCKET.IO CONNECTION HANDLER =====
io.on('connection', (socket) => {
  // Each socket gets a unique ID. We track which room this socket joined.
  let currentRoom = null
  let currentUsername = null

  // --- Event: User joins a room ---
  socket.on('join', ({ username, room }) => {
    // Validate inputs
    if (!username || !rooms[room]) return

    currentRoom = room
    currentUsername = username

    // Join the Socket.io room (a broadcast group)
    socket.join(room)

    // Register this user in our state
    rooms[room].users[socket.id] = username

    // Send the new user the last 50 messages so they see history
    socket.emit('message-history', rooms[room].messages)

    // Tell everyone in the room (including the new user) the updated user list
    io.to(room).emit('room-users', Object.values(rooms[room].users))

    // Broadcast a system message to the rest of the room
    socket.to(room).emit('system-message', {
      text: `${username} joined #${room}`,
      timestamp: Date.now()
    })
  })

  // --- Event: User sends a message ---
  socket.on('send-message', (text) => {
    if (!currentRoom || !currentUsername || !text.trim()) return

    const message = {
      id: Date.now(),
      username: currentUsername,
      text: text.trim().substring(0, 1000),  // Limit message length
      timestamp: Date.now()
    }

    // Save to room history (keep max 50)
    rooms[currentRoom].messages.push(message)
    if (rooms[currentRoom].messages.length > 50) {
      rooms[currentRoom].messages.shift()
    }

    // Broadcast to EVERYONE in the room (including sender)
    io.to(currentRoom).emit('new-message', message)
  })

  // --- Event: Socket disconnects ---
  socket.on('disconnect', () => {
    if (!currentRoom || !currentUsername) return

    // Remove user from room state
    delete rooms[currentRoom].users[socket.id]

    // Tell the room who's still here
    io.to(currentRoom).emit('room-users', Object.values(rooms[currentRoom].users))

    // Broadcast a system message
    io.to(currentRoom).emit('system-message', {
      text: `${currentUsername} left`,
      timestamp: Date.now()
    })
  })
})

server.listen(3000, () => console.log('LiveChat running on http://localhost:3000'))

Understanding the Three Broadcast Methods

This is the most confusing part of Socket.io for first-timers. Pay attention to when you use each:

  • socket.emit('event', data) — sends only to the socket that triggered this event (the sender)
  • socket.to(room).emit('event', data) — sends to everyone in the room except the sender
  • io.to(room).emit('event', data) — sends to everyone in the room including the sender

When a new user joins, we use socket.to(room) for the "joined" announcement (they don't need to see their own join message) but io.to(room) for the user list update (they do need the updated list). When a message is sent, io.to(room) ensures the sender sees their own message appear in the chat.

Step 3: Build the Landing Page UI

AI Prompt

"Write public/index.html — the landing page for LiveChat. It should have: (1) App name 'LiveChat' with a tagline, (2) A form with: a text input for username (placeholder: 'Your name'), three room buttons (General, Tech, Random) that act as a radio select (clicking highlights the active room), a 'Join Chat' submit button. (3) Form validation: username must be 2-20 characters, a room must be selected. (4) On submit, redirect to chat.html?username=NAME&room=ROOM using query params. (5) Style: dark theme (#0A0E1A background), centered card layout, clean modern design. (6) Include Socket.io client script from /socket.io/socket.io.js (Socket.io serves this automatically). No external CSS dependencies."

The Key Part: Passing Data Between Pages

The landing page uses URL query parameters to pass the username and room to the chat page — like chat.html?username=Alex&room=general. No login system, no cookies, no backend call. The chat page reads these with new URLSearchParams(window.location.search). This is a simple trick that keeps the project scope manageable. A real app would use session storage or a proper authentication system.

// In the join form handler (index.html)
form.addEventListener('submit', (e) => {
  e.preventDefault()
  const username = usernameInput.value.trim()
  const room = document.querySelector('.room-btn.active')?.dataset.room

  if (!username || username.length < 2 || !room) return

  // Redirect with data in the URL
  window.location.href = `chat.html?username=${encodeURIComponent(username)}&room=${room}`
})

Step 4: Build the Chat UI

AI Prompt

"Write public/chat.html — the main chat interface for LiveChat. Layout: (1) Left sidebar: app name, list of rooms (General, Tech, Random) with user count badge, current room name highlighted. (2) Main panel: header showing current room name and user count, scrollable message list (messages div), message input form at the bottom with a text input and Send button. (3) Right sidebar: 'Online Now' header with a list of usernames. (4) Message bubbles: my messages on the right with blue background, others on the left with dark-gray background, username above the message for others, timestamp below. (5) System messages (join/leave) centered in gray italic text. (6) Style: dark theme matching index.html, responsive for mobile. (7) Script tag pointing to /js/client.js."

Step 5: Write the WebSocket Client

This is where it all connects. The client JavaScript opens the WebSocket connection, listens for events from the server, and updates the DOM when things change.

AI Prompt

"Write public/js/client.js — the frontend JavaScript for the LiveChat chat page. Requirements: (1) Read username and room from URL query params. If either is missing, redirect to index.html. (2) Connect to Socket.io: const socket = io(). (3) Immediately emit a 'join' event with { username, room }. (4) Listen for 'message-history': render all historical messages, scroll to bottom. (5) Listen for 'new-message': append a new message bubble, scroll to bottom if user is near the bottom (within 100px). (6) Listen for 'system-message': append a centered system message. (7) Listen for 'room-users': update the right sidebar user list. (8) Send button and Enter key: emit 'send-message' with the input text, clear input after sending. (9) Escape all user-provided text before inserting into the DOM (prevent XSS). (10) Add comments explaining the Socket.io event flow."

The Client Code (Core Socket.io Parts)

// public/js/client.js

// --- Read URL params or redirect home ---
const params = new URLSearchParams(window.location.search)
const username = params.get('username')
const room = params.get('room')
if (!username || !room) window.location.href = '/'

// --- Connect to Socket.io ---
// Socket.io client auto-connects to the same host that served this page
const socket = io()

// --- Join the room immediately on connect ---
socket.emit('join', { username, room })

// --- DOM references ---
const messagesEl = document.getElementById('messages')
const messageForm = document.getElementById('message-form')
const messageInput = document.getElementById('message-input')
const userListEl = document.getElementById('user-list')

// ===== INCOMING EVENTS FROM SERVER =====

// Server sends history when you first join
socket.on('message-history', (messages) => {
  messagesEl.innerHTML = ''
  messages.forEach(addMessage)
  scrollToBottom()
})

// A new message was broadcast to this room
socket.on('new-message', (message) => {
  addMessage(message)
  scrollToBottomIfNear()
})

// A user joined or left
socket.on('system-message', ({ text, timestamp }) => {
  const el = document.createElement('div')
  el.className = 'system-message'
  el.textContent = text  // system messages are server-generated, safe to use textContent
  messagesEl.appendChild(el)
  scrollToBottomIfNear()
})

// Updated list of who's online
socket.on('room-users', (users) => {
  userListEl.innerHTML = users
    .map(u => `<li class="user-item">${escapeHTML(u)}</li>`)
    .join('')
})

// ===== SEND A MESSAGE =====
messageForm.addEventListener('submit', (e) => {
  e.preventDefault()
  const text = messageInput.value.trim()
  if (!text) return
  socket.emit('send-message', text)
  messageInput.value = ''
  messageInput.focus()
})

// ===== HELPER FUNCTIONS =====

function addMessage(message) {
  const isMine = message.username === username
  const el = document.createElement('div')
  el.className = `message ${isMine ? 'message-mine' : 'message-theirs'}`
  el.innerHTML = `
    ${!isMine ? `<span class="message-username">${escapeHTML(message.username)}</span>` : ''}
    <div class="message-bubble">${escapeHTML(message.text)}</div>
    <span class="message-time">${formatTime(message.timestamp)}</span>
  `
  messagesEl.appendChild(el)
}

function scrollToBottom() {
  messagesEl.scrollTop = messagesEl.scrollHeight
}

function scrollToBottomIfNear() {
  const distanceFromBottom = messagesEl.scrollHeight - messagesEl.scrollTop - messagesEl.clientHeight
  if (distanceFromBottom < 100) scrollToBottom()
}

function formatTime(timestamp) {
  return new Date(timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
}

function escapeHTML(str) {
  const div = document.createElement('div')
  div.textContent = str
  return div.innerHTML
}

Why scrollToBottomIfNear() Instead of Always Scrolling

If you always auto-scroll on new messages, you yank the user down even when they've scrolled up to read old messages — a classic chat app annoyance. The fix: only auto-scroll if they're already near the bottom (within 100px). This small detail makes the app feel professional.

Step 6: Add Usernames and Rooms

The room switching requires a small addition — clicking a room in the sidebar should move you from one room to another without a page reload.

AI Prompt

"Add room switching to my chat app. When a user clicks a different room in the left sidebar: (1) Emit a 'leave' event to the server for the current room, (2) Emit a 'join' event with the new room, (3) Update the URL query param (without a page reload) using history.pushState, (4) Clear the message list, (5) Request message history for the new room. On the server, handle the 'leave' event: remove the user from the old room, broadcast their departure, add them to the new room on 'join'."

What Changes on the Server

// Add this inside the io.on('connection') block

socket.on('leave', () => {
  if (!currentRoom) return

  // Remove from current room state
  delete rooms[currentRoom].users[socket.id]
  socket.leave(currentRoom)

  // Notify room they're gone
  io.to(currentRoom).emit('room-users', Object.values(rooms[currentRoom].users))
  io.to(currentRoom).emit('system-message', {
    text: `${currentUsername} left #${currentRoom}`,
    timestamp: Date.now()
  })

  currentRoom = null  // Will be set again on the next 'join' event
})

Step 7: Test Locally with Multiple Browser Windows

Start your server and open it in two browser windows side by side. This is how you verify real-time behavior.

node server.js
# Server running at http://localhost:3000

Open http://localhost:3000 in one window, enter the name "Alex" and join General. Open the same URL in another window, enter "Sam" and join General. Type a message as Alex — it should appear instantly in Sam's window. Type as Sam — appears in Alex's. Open a third tab as "Jordan" — they should see the last 50 messages immediately.

If messages don't appear in real time, the WebSocket connection isn't establishing. Check the browser console for errors — the most common issue is the Socket.io client script path (/socket.io/socket.io.js) not loading.

Step 8: Deploy to Railway

Unlike static sites (which deploy anywhere), a chat app needs a persistent server — one that stays running to maintain WebSocket connections. Vercel and Netlify don't support this. Use Railway or Render instead.

AI Prompt

"Help me deploy my Node.js chat app to Railway. What files do I need? Create a Procfile if needed. What environment variables should I set? How do I configure the port — I know Railway injects PORT as an env variable. Update server.js to use process.env.PORT with a fallback to 3000."

The Port Fix (Critical for Deployment)

Platforms like Railway assign a random port via an environment variable. Your server must use it or the app won't start:

// Replace:
server.listen(3000, ...)

// With:
const PORT = process.env.PORT || 3000
server.listen(PORT, () => console.log(`LiveChat running on port ${PORT}`))

Deployment Steps

  1. Push your code to GitHub: git init && git add . && git commit -m "LiveChat initial" && git push
  2. Create a Railway account, click "New Project" → "Deploy from GitHub repo"
  3. Select your repo — Railway auto-detects Node.js and runs npm start
  4. Generate a domain in Railway's settings — your chat app is live

WebSockets work on Railway and Render out of the box. No special configuration needed — Socket.io handles the WebSocket upgrade handshake automatically.

What AI Gets Wrong With WebSockets

AI is very good at generating WebSocket boilerplate. It's less reliable on the edge cases. Here's what to watch for:

1. Not Cleaning Up on Disconnect

AI often generates a connection handler but forgets that sockets disconnect — abruptly, without warning, due to network drops, browser tab closes, or user navigation. Always check that your disconnect handler removes the user from all rooms and broadcasts their departure. A missing disconnect handler causes ghost users (names that stay in the list forever) and memory leaks.

2. Emitting to the Wrong Audience

The three emit methods (socket.emit, socket.to().emit, io.to().emit) confuse AI regularly. The most common mistake: using socket.to(room).emit for new messages (so the sender never sees their own message appear). Double-check every broadcast in your server code and ask: should the sender see this event?

3. No Message Sanitization

AI sometimes inserts message text directly into innerHTML without escaping. This is a serious XSS vulnerability — a user could type <img src=x onerror="steal_your_cookies()"> and it would execute in every connected browser. Always use textContent for user-provided strings, or run them through an escape function before inserting into innerHTML.

4. Unbounded Memory Growth

Without a message limit, your in-memory history grows forever. A long-running server would eventually crash. AI sometimes forgets this. Make sure your message history has a cap (messages.slice(-50) or the shift() approach in the code above) and that you're clearing room state when rooms become empty.

5. No Reconnection Handling on the Frontend

Socket.io automatically reconnects dropped connections — but it doesn't automatically re-join rooms. If a user's connection drops and reconnects, the server sees a new socket with a new ID. The client needs to re-emit join after reconnecting. Tell your AI: "Handle the Socket.io 'reconnect' event on the client by re-emitting the join event."

6. Treating Socket.io Rooms Like Persistent State

Socket.io rooms disappear when all sockets leave. Your application state (the rooms object in this tutorial) is separate from Socket.io's internal room tracking. Don't confuse them. The rooms object on the server is your source of truth for message history and user lists — Socket.io rooms are just broadcast groups.

Understanding the Full Data Flow

Walk through what happens when Alex types "hello" and hits send:

  1. Alex's browser calls socket.emit('send-message', 'hello')
  2. This message travels over the WebSocket connection to your Node.js server
  3. The server's socket.on('send-message') handler fires
  4. Server creates a message object: { id, username: 'Alex', text: 'hello', timestamp }
  5. Server saves it to rooms.general.messages
  6. Server calls io.to('general').emit('new-message', message)
  7. Socket.io finds every socket that's joined the 'general' room — Alex, Sam, Jordan
  8. It sends the new-message event to all three simultaneously
  9. Each browser's socket.on('new-message') fires, calling addMessage()
  10. The DOM updates in all three windows — instantly

The whole round trip (client → server → all clients) happens in under 50ms on a typical connection. That's what makes it feel live.

What to Build Next

Now that you have a working chat app, here are the natural extensions — each one teaches a new concept:

Typing Indicators

Show "Alex is typing..." when someone's composing a message. Tell your AI: "Add typing indicators. When the user starts typing (input event), emit a 'typing' event. When they stop typing for 1 second (debounce), emit 'stopped-typing'. The server broadcasts both events to the room. The frontend shows a typing indicator below the message list."

This teaches you debouncing — preventing too many events from firing in rapid succession.

File and Image Sharing

Let users send images in chat. This requires a file upload endpoint (Express + Multer) that stores the file and returns a URL, then the client sends that URL as a message. Longer project — teaches multipart form uploads and file storage.

Persistent Message History

Replace the in-memory array with a database. Tell your AI: "Replace the in-memory message history with Supabase. Each message should be saved to a 'messages' table with columns: id, room, username, text, created_at. On join, query the last 50 messages for that room from Supabase instead of from the in-memory array." This is your bridge from stateless apps to stateful, persistent ones.

Read Receipts

Show who has read each message. This requires tracking which messages each user has seen — a more complex state management challenge that mirrors how production apps like Slack work.

Private Messages

Allow users to click a username and open a private conversation. Tell your AI: "Add private messaging. A user can click another user's name to open a DM. Private messages go only to that socket's ID using socket.to(socketId).emit(). Track a map of username to socketId on the server."

What to Learn Next

Frequently Asked Questions

What is a WebSocket and how is it different from a regular HTTP request?

HTTP is one-directional: your browser asks, the server answers, the connection closes. WebSockets create a persistent two-way tunnel between your browser and server. Once connected, either side can send data at any time — no new request needed. That's what makes real-time chat possible: when User A sends a message, the server pushes it instantly to User B without B having to refresh or poll.

Do I need to know Node.js to build this chat app?

No deep knowledge required. You need Node.js installed, and you'll use AI to generate most of the code. The tutorial explains what each piece does in plain English. If you understand that Node.js lets JavaScript run on a server (not just in a browser), you have enough to get started.

What is Socket.io and why use it instead of raw WebSockets?

Socket.io is a library that wraps WebSockets and adds features: automatic reconnection when connections drop, fallback to HTTP polling for old browsers, rooms (groups of connections), and a simpler event-based API. Raw WebSockets work fine, but Socket.io handles edge cases AI is more likely to get wrong. For learning real-time apps, Socket.io is the right starting point.

How do I store chat history so messages survive a page refresh?

In this tutorial the server keeps messages in memory (a JavaScript array) — fast, simple, but lost when the server restarts. For persistent history, you'd connect a database: SQLite for local dev, Postgres or Supabase for production. Tell your AI: "Add message persistence using Supabase. Store each message with content, username, room, and timestamp." The WebSocket layer stays the same — you just save to the database before broadcasting.

Can I deploy this chat app for free?

Yes. Railway and Render both have free tiers that support persistent Node.js servers (needed for WebSockets). Vercel and Netlify are serverless — they don't support persistent connections, so they won't work for WebSocket backends. Deploy your Node.js/Socket.io server to Railway or Render, and either host your frontend separately on Vercel or serve it from the same Express server.

What's the difference between a chat room and a WebSocket namespace?

Rooms are subgroups within a single Socket.io connection — a socket can join multiple rooms simultaneously. Namespaces are separate connection endpoints (like /chat vs /notifications). For a chat app, rooms are what you want: one user can be in #general and #random at the same time on a single connection. Namespaces are better for separating unrelated features of the same app.