HTTP is a request-response protocol — the client asks, the server responds, and the connection closes. This works perfectly for fetching data, but it falls apart when you need bidirectional, low-latency communication: think chat apps, live dashboards, multiplayer games, or collaborative editors.
That's exactly where WebSockets shine. A WebSocket connection stays open, allowing both the server and the client to push data at any time without the overhead of repeatedly establishing new connections. In this tutorial, we'll build a real-time multi-user chat room from scratch — no Socket.io, no heavy libraries — just the native WebSocket API and a simple Node.js server.
Step 1: Setting Up the Node.js WebSocket Server
We'll use the ws package, which is a fast, minimal WebSocket library for Node.js. Create a project folder and initialise it:
mkdir ws-chat && cd ws-chat
npm init -y
npm install ws
Create a server.js file. The server's job is to accept incoming connections and broadcast every message it receives to all connected clients:
// server.js
const { WebSocketServer } = require('ws');
const wss = new WebSocketServer({ port: 8080 });
wss.on('connection', (ws) => {
console.log('Client connected. Total:', wss.clients.size);
// When a message arrives, broadcast it to every connected client
ws.on('message', (data) => {
const message = data.toString();
wss.clients.forEach((client) => {
// ReadyState 1 = OPEN
if (client.readyState === 1) {
client.send(message);
}
});
});
ws.on('close', () => {
console.log('Client disconnected. Total:', wss.clients.size);
});
// Send a welcome message to the new client only
ws.send(JSON.stringify({ type: 'system', text: 'Welcome to the chat!' }));
});
console.log('WebSocket server running on ws://localhost:8080');
Start the server with:
node server.js
Step 2: The HTML Structure
Create an index.html file that contains the chat UI — a message list, a username input, a message input, and a send button.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>WebSocket Chat</title>
<style>
body { font-family: sans-serif; max-width: 600px; margin: 2rem auto; padding: 0 1rem; }
#messages { border: 1px solid #ddd; border-radius: 8px; height: 350px;
overflow-y: auto; padding: 1rem; margin-bottom: 1rem; background: #f9f9f9; }
.msg { margin-bottom: 0.5rem; }
.msg strong { color: #6c63ff; }
.msg.system { color: #999; font-style: italic; }
.controls { display: flex; gap: 0.5rem; }
input { flex: 1; padding: 0.5rem 0.75rem; border: 1px solid #ccc; border-radius: 6px; }
button { padding: 0.5rem 1rem; background: #6c63ff; color: #fff;
border: none; border-radius: 6px; cursor: pointer; }
</style>
</head>
<body>
<h1>💬 Live Chat</h1>
<div id="messages"></div>
<div class="controls">
<input type="text" id="username" placeholder="Your name" style="max-width:130px">
<input type="text" id="msg-input" placeholder="Type a message..." />
<button id="send-btn">Send</button>
</div>
<script src="app.js"></script>
</body>
</html>
Step 3: The Client-Side JavaScript
Create app.js. The browser's native WebSocket constructor takes a URL and gives you four event handlers to work with: onopen, onmessage, onerror, and onclose.
// app.js
const ws = new WebSocket('ws://localhost:8080');
const messagesEl = document.getElementById('messages');
const msgInput = document.getElementById('msg-input');
const sendBtn = document.getElementById('send-btn');
const usernameEl = document.getElementById('username');
// Helper to append a message to the list
function appendMessage({ type, sender, text }) {
const div = document.createElement('div');
div.className = 'msg' + (type === 'system' ? ' system' : '');
if (type === 'system') {
div.textContent = text;
} else {
div.innerHTML = `<strong>${escapeHtml(sender)}</strong>: ${escapeHtml(text)}`;
}
messagesEl.appendChild(div);
messagesEl.scrollTop = messagesEl.scrollHeight;
}
// Basic XSS guard
function escapeHtml(str) {
return str.replace(/[<>&"]/g, (c) => ({ '<': '<', '>': '>', '&': '&', '"': '"' }[c]));
}
// Receive messages from the server
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
appendMessage(data);
};
ws.onopen = () => console.log('Connected to server');
ws.onerror = (err) => console.error('WebSocket error:', err);
ws.onclose = () => appendMessage({ type: 'system', text: 'Disconnected from server.' });
// Send a message
function sendMessage() {
const text = msgInput.value.trim();
const sender = usernameEl.value.trim() || 'Anonymous';
if (!text || ws.readyState !== WebSocket.OPEN) return;
ws.send(JSON.stringify({ type: 'chat', sender, text }));
msgInput.value = '';
}
sendBtn.addEventListener('click', sendMessage);
msgInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') sendMessage(); });
Notice that we're sending structured JSON objects instead of plain strings. This gives us a type field we can use to style system messages differently from user chat messages.
Step 4: Broadcasting Usernames on the Server
Our server currently just forwards whatever string it receives. Since we're now sending JSON with a sender field, the server should forward the entire parsed object. Update server.js to handle this gracefully:
ws.on('message', (data) => {
let parsed;
try {
parsed = JSON.parse(data.toString());
} catch {
return; // ignore malformed messages
}
const payload = JSON.stringify(parsed);
wss.clients.forEach((client) => {
if (client.readyState === 1) {
client.send(payload);
}
});
});
Step 5: Running & Testing the App
Open two steps in your terminal:
- Start the server:
node server.js - Open
index.htmlin two separate browser tabs (you can use Live Server in VS Code, or just open the file directly).
Type a message in one tab and click Send — it should appear instantly in both tabs. You now have a working real-time chat application with zero framework overhead.
Conclusion
WebSockets are one of the most powerful primitives available in modern browsers. By keeping a persistent connection open, you eliminate the polling overhead that plagued older real-time approaches and enable truly instant, bidirectional communication. From here you could extend this app by adding rooms, user presence indicators, message history stored in a database, or TLS encryption for production deployment.