Build a Real-Time Chat with WebSockets from Scratch

Coding ProjectsBuild a Real-Time Chat with WebSockets from Scratch

Tired of waiting for new messages and scraping the server every few seconds?
In this step-by-step guide you’ll build a real-time chat with WebSockets from scratch that truly feels instant.
We’ll make a Node.js backend that keeps sockets open, a simple frontend that sends and renders messages, and optional message persistence so history survives restarts.
You’ll run the frontend, backend, and database in separate terminals, see the live message flow, and learn tips for rooms, typing indicators, and basic security.
By the end, you’ll have a working chat app and the patterns to reuse in other projects.

High-Level Overview of Building the Real-Time WebSocket Chat

ierMIP0PWne458tRcfAqjQ

Building a real-time chat means creating a server that keeps connections open and pushes messages immediately instead of waiting for the client to ask for updates. WebSocket connections stay alive, so when one user sends a message, the server can instantly forward it to everyone else in the chat. You’ll run separate terminals for the frontend, backend, and database, all at the same time.

The build follows a simple pattern: create a backend that listens for WebSocket events, stores messages, and broadcasts to all connected clients. Then build a frontend that opens a WebSocket connection, sends messages through that connection, and updates the UI whenever the server pushes new data.

Here’s the high-level flow:

  1. Create the project and prepare the environment. Set up folders, install dependencies, and configure scripts to run the frontend, backend, and database concurrently.
  2. Implement a backend WebSocket server that relays messages. Handle connect, disconnect, and message events, broadcast incoming messages to all clients, and optionally persist messages to a database.
  3. Build a frontend client that sends and receives messages in real time. Open a WebSocket connection, listen for server events, render messages in the UI, and send new messages when the user submits the form.

Project Setup for a WebSocket Chat Application

EfdsI6JtWIKZgGqn_phKsg

Start by creating a project directory and initializing a new Node.js project. Open your terminal and run mkdir webchat && cd webchat && npm init -y to generate a default package.json. You’ll need Node.js version 12 or higher installed on your machine.

Next, install the core dependencies. Run npm install express ws to add Express for serving static files and the ws library for WebSocket server functionality. Install nodemon as a dev dependency with npm install -D nodemon so the server restarts automatically when you save changes. Open package.json and add a script "dev:server": "nodemon index.js" so you can start the backend with npm run dev:server. If you’re using a separate frontend build tool like Vite or Create React App, add a second script "dev": "vite" or similar to run the frontend on a different port.

Your project structure should look like this:

index.js is your main backend entry point. The public/ folder holds static assets (HTML, CSS, client-side JavaScript). Inside that, public/index.html contains your login or chat UI, and public/js/main.js handles client WebSocket logic. You can add a helper/ folder for utility functions like message formatting or user management if you want.

Implementing the WebSocket Backend for Real-Time Chat

IjcO1lxGVIOnPRuVdm6CFg

Connection and Message Flow

Create index.js in the project root. Import Express, the built-in http module, and the ws library. Set up an Express app, create an HTTP server by wrapping the Express app with http.createServer(app), and attach the WebSocket server to that HTTP server using new WebSocket.Server({ server }). Serve static files from the public/ folder with app.use(express.static('public')).

When a client connects, the WebSocket server fires a connection event and gives you a socket object representing that client.

Inside the connection handler, listen for message events on the socket. Each message arrives as a buffer. Parse it with JSON.parse(data.toString()) to get an object with fields like method, username, and text. When the connection closes, the close event fires, and you can clean up any stored references to that client. Store all active WebSocket connections in a Set or array so you can loop through them when broadcasting messages.

Broadcasting Messages

When a client sends a message, the server should relay it to everyone in the chat. Loop through your stored connections and call socket.send(JSON.stringify({ method: 'new-message', username, text, timestamp })) on each one.

Keep the server as the source of truth by saving the message to your database first, then broadcasting it. This prevents the frontend from appending messages locally and ensures every client sees the same sequence of messages. If you want to support chat rooms, add a roomId field to each message and only broadcast to sockets that have joined that room.

Building the WebSocket Frontend Client

StjAsjQLWoaZDSFu2gacVw

On the frontend, open a WebSocket connection with const socket = new WebSocket('ws://localhost:8001') as soon as the page loads. Listen for the open event to confirm the connection is ready, then send a join message if you’re using room-based chat. When the server pushes a message, the message event fires. You can parse event.data as JSON and append it to the message list in the DOM.

Attach a submit handler to your message form. When the user clicks send, call event.preventDefault(), grab the text from the input, trim it, and return early if it’s empty. Send the message to the server with socket.send(JSON.stringify({ method: 'send-message', text })), then clear the input field.

Wait for the server to broadcast the message back before appending it to the UI. This way you don’t create duplicate messages if the server adds metadata like timestamps or user IDs.

Feature Purpose
Connect WebSocket Establish a persistent connection to the backend server on page load
Receive messages Listen for the message event and parse incoming JSON to update the chat UI
Send messages Stringify user input and send it over the socket when the form is submitted
Update UI Append new messages to the message container and display username, text, and timestamp
Auto-scroll Scroll the message container to the bottom when a new message arrives

Adding Chat Rooms, Typing Indicators, and Presence

hwFBcv6hX3i_3xJpeMpS_w

To support chat rooms, send a join-room message from the client with the room name after the WebSocket opens. On the backend, store each socket along with its room ID. When a message arrives, check the sender’s room and only broadcast to sockets in that same room.

Send a room-users-update event whenever someone joins or leaves to refresh the active user list on every client. If you want to notify users when someone joins, emit a user-presence message with the new user’s name. Optionally inject a small system message like “Alice joined the room” into the message feed.

Typing indicators work by sending a typing event with isTyping: true when the user starts typing and isTyping: false after a short delay or when they stop. Debounce the typing signal by clearing and resetting a timeout on each keypress so you don’t flood the server with dozens of events. Broadcast the typing state to other users in the room and display a small indicator like “Bob is typing…” below the message list.

Presence tracking relies on keeping an in-memory list of users in each room. When a client disconnects, remove the user from the list and broadcast an updated room-users-update event. The frontend listens for this event and re-renders the user list in the sidebar. If you want to show who’s online across all rooms, maintain a separate set of connected user IDs and emit a global presence update whenever anyone connects or disconnects.

Persisting Messages with a Database (PostgreSQL or MongoDB)

MSMFct-9XgKo2BfZ212pAw

Database Setup & Schema

To save messages permanently, connect the backend to a database. If you’re using PostgreSQL, create a docker-compose.yml file with the official Postgres image, expose port 5432, and set environment variables for the database name, username, and password. Run docker compose up in a third terminal to start the database container.

Create a db.js file that imports the pg library and exports a connection pool configured with the same credentials from your compose file. In a queries.sql file, define a messages table with columns for id, room_id, username, text, and timestamp. Add an index on (room_id, timestamp) to speed up history queries.

For MongoDB, install Mongoose and define a Message schema with fields roomId, username, text, and timestamp. Use Message.find({ roomId }).sort({ timestamp: -1 }).limit(50) to fetch recent messages when a user joins a room.

Store the message immediately after receiving it on the backend and before broadcasting it to other clients. If you want to support pagination, add REST endpoints like GET /api/messages/:roomId?page=1&limit=20 so clients can load older messages without reopening the WebSocket. Send an initial batch of messages over the WebSocket connection right after the client sends the join-room event so the chat feels complete when the page loads.

Securing a WebSocket Chat (JWT, Validation, Abuse Protection)

3wgKSewUxyChXtFpCmcNA

Security starts with authentication. Generate a JSON Web Token when the user logs in and store it in localStorage on the frontend. When opening the WebSocket connection, include the token in the first message, like { method: 'join-room', token, roomId }. On the backend, verify the token using jsonwebtoken.verify(token, SECRET_KEY) before processing any events. Cache the decoded user info on the WebSocket connection object so you don’t re-verify the token on every message.

Follow these steps to lock down your chat:

  1. Verify tokens before processing events. Reject any WebSocket message that doesn’t include a valid JWT or fails verification.
  2. Sanitize message text. Strip HTML tags or use a library like DOMPurify to prevent users from injecting scripts into the chat.
  3. Prevent spam. Track message timestamps per user and block clients who send more than 10 messages per second.
  4. Rate-limit room joins. Limit each user to joining one room every few seconds to prevent rapid room hopping or denial-of-service attempts.

Running, Testing, and Debugging Your WebSocket Chat

TJlcgOEwVwSSAjGTmfWmvw

Start three terminals. In the first, run docker compose up to launch the database. In the second, run npm run dev:server to start the backend. In the third, run npm run dev to start the frontend.

Open two browser windows and navigate to http://localhost:3000 in both. Join the same room in each window and send a message from one. The message should appear instantly in both windows.

If messages aren’t appearing, check the browser console for WebSocket connection errors and verify the backend logs to confirm the server is receiving and broadcasting messages. Run docker ps to confirm the database container is running, then use docker exec -it <container_name> psql -U <username> -d <database> to enter the Postgres container and query the messages table directly.

Common issues and fixes:

WebSocket won’t connect. Confirm the backend server is running and listening on the correct port. Check for CORS or protocol mismatches (ws vs. wss).

Messages appear twice. Remove any local append logic from the frontend and only update the UI when the server broadcasts the message.

Old messages don’t load. Verify the database query runs on join and that the server sends the history in the correct event format.

Users see stale user lists. Emit room-users-update after every join and disconnect. Make sure the frontend listens for that event and re-renders the list.

Final Words

Start by creating the project, install the WebSocket library, and spin up separate frontend and backend terminals so messages can flow. Then implement server events (connect, message, disconnect), build a client to send and receive JSON messages, and add rooms or typing indicators as needed.

Persist history with Postgres or MongoDB, secure with JWT and sanitization, and test by opening two browsers and watching messages sync.

If you follow these steps you’ll successfully build a real-time chat with WebSockets step by step. Happy building!

FAQ

Q: What is a real-time WebSocket chat and how does it work?

A: A real-time WebSocket chat keeps a persistent connection between client and server, handling connect, disconnect, and message events so messages flow instantly between users.

Q: How do I set up a new project and install dependencies?

A: To set up, create project folders, run npm init -y, install express and ws or socket.io, add nodemon and dev scripts, then start frontend/backend with npm run dev and npm run dev:server.

Q: How should the backend handle connections, messages, and broadcasting?

A: The backend should accept WebSocket connections, verify clients, handle onmessage and onclose, persist or validate messages, and broadcast received messages to all connected clients as the source of truth.

Q: What does the frontend client need to do?

A: The frontend client must open a WebSocket, fetch chat history, listen for incoming events, send messages, update the UI with usernames/timestamps, and auto-scroll to show new messages.

Q: How do I add chat rooms, typing indicators, and presence tracking?

A: To add rooms and presence, implement join-room and typing events, broadcast room-users updates, debounce typing signals on the client, and send presence changes from the server.

Q: How can I persist messages and load chat history?

A: Persist messages by saving them to Postgres or MongoDB (schemas include roomId and timestamp), then load history via a REST endpoint or an initial WebSocket history event.

Q: How should I secure a WebSocket chat against unauthorized access and abuse?

A: Secure a WebSocket chat by verifying JWTs before processing events, sanitizing inputs to prevent XSS, and adding rate limits or spam protection on message events.

Q: How do I run, test, and debug the chat locally?

A: Run frontend and server in separate terminals, open two browsers to verify real-time updates, use docker ps for DB status, and inspect reconnects, disconnects, and console errors for bugs.

Check out our other content

Check out other tags: