Introduction to WebSockets and Real-Time Chat
Building a chat application is one of the most rewarding projects for a developer. It touches on real-time communication, client-server architecture, and user interface design. At the heart of any modern chat app lies WebSockets — a protocol that enables persistent, bidirectional communication between a browser and a server. This tutorial walks you through building a fully functional chat application from scratch using WebSockets and Node.js, complete with usernames, join/leave notifications, typing indicators, and production-ready best practices.
What Are WebSockets?
WebSockets are a communication protocol that provides full-duplex (two-way) communication channels over a single TCP connection. Unlike traditional HTTP requests — where the client sends a request and the server responds — WebSockets allow both the client and server to send data at any time without waiting for the other side. Once a WebSocket connection is established, it stays open, enabling real-time data exchange with minimal overhead and latency.
The WebSocket protocol is distinct from HTTP, but it uses the HTTP upgrade mechanism to initiate the connection. After the handshake, the connection switches from HTTP to the WebSocket protocol, and data frames flow freely in both directions.
Why WebSockets Matter for Chat Applications
For a chat application, real-time delivery is not a luxury — it's the entire product. Alternatives like HTTP long-polling or Server-Sent Events (SSE) have significant drawbacks for chat use cases:
- HTTP Polling — The client repeatedly asks the server "any new messages?" This creates enormous overhead from constant HTTP headers and connection setup/teardown for every poll cycle. It wastes bandwidth and introduces latency.
- Long-Polling — The server holds the request open until new data is available. Better than simple polling, but still limited to half-duplex communication and carries HTTP header overhead for each response.
- Server-Sent Events (SSE) — Allows the server to push data to the client over a single HTTP connection. However, SSE is unidirectional (server-to-client only). For a chat app, clients also need to send messages, which would require a separate HTTP request mechanism.
- WebSockets — True bidirectional, persistent connection. Minimal framing overhead (as low as 2 bytes per frame). Ideal for low-latency, high-frequency message exchange where both parties initiate communication.
WebSockets deliver messages instantly to all connected clients, handle thousands of concurrent connections efficiently, and provide the seamless experience users expect from modern chat applications.
Project Setup and Dependencies
🚀 Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →Initialize the Node.js Project
Create a new directory for the project and initialize it with npm. We'll use the ws library, which is a pure WebSocket implementation for Node.js — fast, well-maintained, and widely adopted.
mkdir websocket-chat-app
cd websocket-chat-app
npm init -y
npm install ws
The ws package provides both server and client WebSocket functionality. For the server, we'll use it to listen for connections and manage clients. For the client side, we'll use the native WebSocket API built into modern browsers — no additional client library is needed.
Project Structure
Our project will have two main files:
websocket-chat-app/
├── server.js # Node.js WebSocket server
├── public/
│ └── index.html # Client-side chat interface
└── package.json
The server will serve the static HTML file and handle all WebSocket communication. The client HTML file contains both the chat UI markup and the client-side JavaScript that connects to the WebSocket server.
Building the WebSocket Server
Basic Server Setup
We'll create a server that does two things: serves the static HTML file over HTTP and handles WebSocket connections. Node.js's built-in http module combined with ws makes this straightforward.
// server.js
const http = require('http');
const fs = require('fs');
const path = require('path');
const { WebSocketServer } = require('ws');
const PORT = 3000;
// Create an HTTP server to serve the static client file
const server = http.createServer((req, res) => {
if (req.url === '/' || req.url === '/index.html') {
const filePath = path.join(__dirname, 'public', 'index.html');
const htmlContent = fs.readFileSync(filePath, 'utf8');
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(htmlContent);
} else {
res.writeHead(404);
res.end('Not Found');
}
});
// Attach the WebSocket server to the HTTP server
const wss = new WebSocketServer({ server });
console.log(`Server running on http://localhost:${PORT}`);
console.log('WebSocket server is ready to accept connections');
server.listen(PORT);
This gives us a foundation. The HTTP server delivers the chat interface, and the WebSocket server is attached to the same port, sharing the underlying TCP server. This is convenient because browsers don't face cross-origin complications when the WebSocket endpoint shares the same origin as the page.
Handling Connections and Messages
Now we'll add the core WebSocket logic. When a new client connects, we need to track that connection, listen for incoming messages, and handle disconnections. We'll maintain a Set of connected clients to enable broadcasting.
// server.js (continued — replace the wss setup and add before server.listen)
const clients = new Set();
wss.on('connection', (ws, req) => {
// Add the new client to our tracked set
clients.add(ws);
const clientIP = req.socket.remoteAddress;
console.log(`New client connected from ${clientIP}. Total clients: ${clients.size}`);
// Listen for messages from this client
ws.on('message', (data, isBinary) => {
const message = isBinary ? data : data.toString();
console.log(`Received: ${message}`);
// Broadcast to all connected clients
broadcast(message, ws);
});
// Handle client disconnection
ws.on('close', () => {
clients.delete(ws);
console.log(`Client disconnected. Total clients: ${clients.size}`);
});
// Handle errors
ws.on('error', (err) => {
console.error('WebSocket error:', err.message);
clients.delete(ws);
});
});
Broadcasting Messages to All Clients
The broadcast function iterates through all connected clients and sends the message to each one. We pass the sender's WebSocket reference so we can optionally exclude the sender from receiving their own message echo — though for a chat app, echoing back to the sender confirms message delivery.
function broadcast(message, senderWs = null) {
const messageString = typeof message === 'string' ? message : message.toString();
for (const client of clients) {
// Only send to clients whose connection is still open
if (client.readyState === client.OPEN) {
// Optionally skip the sender if you don't want echo
// if (client === senderWs) continue;
client.send(messageString);
}
}
}
At this point, we have a basic anonymous chat. Messages typed by any user appear for everyone. But a real chat app needs identifiable users and richer interaction.
Adding Username Support
We'll implement a simple protocol where the first message a client sends after connecting is treated as their username. Subsequent messages are actual chat messages. We'll structure messages as JSON to carry metadata like the sender's name, the message type, and the content.
// server.js — Updated connection handler with username support
wss.on('connection', (ws, req) => {
clients.add(ws);
let username = null; // Will be set on first message
console.log(`New client connected. Total clients: ${clients.size}`);
ws.on('message', (data) => {
const messageStr = data.toString();
// First message from this client is their username
if (username === null) {
username = messageStr.trim() || 'Anonymous';
// Notify everyone that a new user joined
const joinMsg = JSON.stringify({
type: 'system',
content: `${username} joined the chat`,
timestamp: Date.now()
});
broadcast(joinMsg);
// Send the new user a welcome message with the current user list
const userList = Array.from(clients)
.filter(c => c !== ws && c.username)
.map(c => c.username);
ws.send(JSON.stringify({
type: 'welcome',
content: `Welcome, ${username}!`,
users: userList,
timestamp: Date.now()
}));
return;
}
// Subsequent messages are chat messages
const chatMsg = JSON.stringify({
type: 'chat',
username: username,
content: messageStr,
timestamp: Date.now()
});
broadcast(chatMsg);
});
ws.on('close', () => {
clients.delete(ws);
const leaveMsg = JSON.stringify({
type: 'system',
content: `${username || 'A user'} left the chat`,
timestamp: Date.now()
});
broadcast(leaveMsg);
console.log(`${username || 'Unknown'} disconnected. Total clients: ${clients.size}`);
});
ws.on('error', (err) => {
console.error(`Error from ${username || 'unknown'}:`, err.message);
clients.delete(ws);
});
});
This is a significant improvement. Users now have identities, join/leave events are visible to everyone, and new users receive a welcome message along with the list of currently connected users.
Handling User Join/Leave Notifications
The join and leave notifications are already handled in the code above through type: 'system' messages. On the client side, we'll render these differently — perhaps with a distinct style (gray text, italic) to distinguish them from regular chat messages. The server broadcasts these system messages to all connected clients except (in the case of join) the new user themselves, who receives a dedicated welcome message instead.
Typing Indicators
Typing indicators add a polished feel to chat applications. Instead of sending full messages, the client sends lightweight "typing" events when the user starts and stops typing. The server relays these to other clients. Here's how to extend the server:
// Inside ws.on('message', ...) — add a JSON parsing layer for typed messages
ws.on('message', (data) => {
const messageStr = data.toString();
// First message is still the username (plain string)
if (username === null) {
username = messageStr.trim() || 'Anonymous';
const joinMsg = JSON.stringify({
type: 'system',
content: `${username} joined the chat`,
timestamp: Date.now()
});
broadcast(joinMsg);
const userList = Array.from(clients)
.filter(c => c !== ws && c.username)
.map(c => c.username);
ws.send(JSON.stringify({
type: 'welcome',
content: `Welcome, ${username}!`,
users: userList,
timestamp: Date.now()
}));
return;
}
// Try to parse as JSON (for typed events like typing indicators)
let parsed;
try {
parsed = JSON.parse(messageStr);
} catch (e) {
// If not valid JSON, treat as a regular chat message
const chatMsg = JSON.stringify({
type: 'chat',
username: username,
content: messageStr,
timestamp: Date.now()
});
broadcast(chatMsg);
return;
}
// Handle structured messages
if (parsed.type === 'typing') {
const typingMsg = JSON.stringify({
type: 'typing',
username: username,
isTyping: parsed.isTyping
});
broadcast(typingMsg, ws); // Don't send back to sender
} else if (parsed.type === 'chat') {
const chatMsg = JSON.stringify({
type: 'chat',
username: username,
content: parsed.content,
timestamp: Date.now()
});
broadcast(chatMsg);
}
});
This hybrid approach — accepting both plain strings (for simple chat messages) and JSON (for structured events like typing indicators) — keeps the protocol flexible while remaining backward-compatible with simpler clients.
Building the Client-Side Interface
HTML Structure
The client is a single HTML file that provides the chat UI and all client-side logic. Let's build a clean, modern interface.
<!-- public/index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WebSocket Chat</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Segoe UI', system-ui, sans-serif;
background: #1a1a2e;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
color: #e0e0e0;
}
.chat-container {
width: 100%;
max-width: 650px;
height: 90vh;
background: #16213e;
border-radius: 16px;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: 0 8px 40px rgba(0,0,0,0.4);
}
.chat-header {
padding: 20px 24px;
background: #0f3460;
border-bottom: 1px solid #1a1a4e;
}
.chat-header h2 {
font-size: 1.3rem;
color: #e94560;
}
.chat-header .status {
font-size: 0.8rem;
color: #a0a0c0;
margin-top: 4px;
}
.messages-area {
flex: 1;
overflow-y: auto;
padding: 20px 24px;
display: flex;
flex-direction: column;
gap: 8px;
}
.message {
padding: 10px 14px;
border-radius: 12px;
max-width: 75%;
word-wrap: break-word;
animation: fadeIn 0.2s ease;
}
@keyframes fadeIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
.message.chat {
background: #1a1a4e;
align-self: flex-start;
}
.message.chat.own {
background: #e94560;
align-self: flex-end;
color: #fff;
}
.message .username {
font-weight: 700;
font-size: 0.8rem;
margin-bottom: 4px;
color: #e94560;
}
.message.chat.own .username {
color: #ffccd5;
}
.message .text {
font-size: 0.95rem;
}
.message .time {
font-size: 0.65rem;
color: #8888aa;
margin-top: 6px;
text-align: right;
}
.message.system {
background: transparent;
align-self: center;
max-width: 90%;
text-align: center;
color: #8888aa;
font-style: italic;
font-size: 0.85rem;
padding: 6px 0;
}
.typing-indicator {
padding: 6px 24px;
font-size: 0.8rem;
color: #a0a0c0;
font-style: italic;
min-height: 24px;
}
.input-area {
padding: 16px 24px;
background: #0f3460;
display: flex;
gap: 10px;
border-top: 1px solid #1a1a4e;
}
.input-area input {
flex: 1;
padding: 12px 16px;
border-radius: 24px;
border: 2px solid #1a1a4e;
background: #16213e;
color: #e0e0e0;
font-size: 0.95rem;
outline: none;
transition: border-color 0.2s;
}
.input-area input:focus {
border-color: #e94560;
}
.input-area button {
padding: 12px 20px;
border-radius: 24px;
border: none;
background: #e94560;
color: #fff;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.input-area button:hover {
background: #d63850;
}
.input-area button:disabled {
background: #555;
cursor: not-allowed;
}
</style>
</head>
<body>
<div class="chat-container">
<div class="chat-header">
<h2>💬 WebSocket Chat</h2>
<div class="status" id="status">Connecting...</div>
</div>
<div class="messages-area" id="messages"></div>
<div class="typing-indicator" id="typingIndicator"></div>
<div class="input-area">
<input type="text" id="messageInput" placeholder="Type a message..." autocomplete="off" />
<button id="sendBtn">Send</button>
</div>
</div>
<script>
// Client-side JavaScript — see below
</script>
</body>
</html>
Client-Side JavaScript with the WebSocket API
Modern browsers include a built-in WebSocket class. We'll use it to connect, send messages, and react to incoming data. The script goes inside the <script> tag at the bottom of the HTML file.
Connecting to the Server
We establish the WebSocket connection to the same host that served the page. The wss:// protocol is for secure WebSockets (equivalent to HTTPS), while ws:// is for plain connections. Since we're running locally without SSL, we'll use ws://.
// Inside the <script> tag in index.html
let username = null;
let ws = null;
let typingTimeout = null;
let isTyping = false;
// Determine the WebSocket URL based on the page's protocol
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}`;
function connect() {
ws = new WebSocket(wsUrl);
ws.onopen = () => {
console.log('Connected to WebSocket server');
document.getElementById('status').textContent = '🟢 Connected';
document.getElementById('sendBtn').disabled = false;
document.getElementById('messageInput').disabled = false;
// Prompt for username and send it as the first message
username = prompt('Enter your username:') || 'Anonymous';
ws.send(username);
document.getElementById('status').textContent = `🟢 Chatting as ${username}`;
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
handleMessage(data);
};
ws.onclose = () => {
console.log('Disconnected from server');
document.getElementById('status').textContent = '🔴 Disconnected — reconnecting...';
document.getElementById('sendBtn').disabled = true;
document.getElementById('messageInput').disabled = true;
// Auto-reconnect after 3 seconds
setTimeout(connect, 3000);
};
ws.onerror = (err) => {
console.error('WebSocket error:', err);
};
}
// Start the connection
connect();
Sending and Receiving Messages
The send logic handles both regular chat messages and typing indicator events. When the user types in the input field, we send a typing event; when they submit the form, we send the actual message.
// Continued inside the <script> tag
const messageInput = document.getElementById('messageInput');
const sendBtn = document.getElementById('sendBtn');
const messagesArea = document.getElementById('messages');
const typingIndicatorEl = document.getElementById('typingIndicator');
// Send a chat message
function sendMessage() {
const text = messageInput.value.trim();
if (!text || !ws || ws.readyState !== WebSocket.OPEN) return;
// Send as JSON with type 'chat' for consistency
ws.send(JSON.stringify({ type: 'chat', content: text }));
messageInput.value = '';
// Clear typing state
if (isTyping) {
isTyping = false;
ws.send(JSON.stringify({ type: 'typing', isTyping: false }));
}
}
sendBtn.addEventListener('click', sendMessage);
messageInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
sendMessage();
}
});
// Typing indicator — send typing events with debounce
messageInput.addEventListener('input', () => {
if (!ws || ws.readyState !== WebSocket.OPEN) return;
if (!isTyping) {
isTyping = true;
ws.send(JSON.stringify({ type: 'typing', isTyping: true }));
}
// Reset the typing timeout
clearTimeout(typingTimeout);
typingTimeout = setTimeout(() => {
if (isTyping) {
isTyping = false;
ws.send(JSON.stringify({ type: 'typing', isTyping: false }));
}
}, 2000); // Stop typing after 2 seconds of inactivity
});
Displaying Messages in the UI
The handleMessage function renders different message types — chat messages, system notifications, typing indicators, and welcome messages — with appropriate styling.
// Continued — message rendering logic
function handleMessage(data) {
switch (data.type) {
case 'chat':
renderChatMessage(data);
break;
case 'system':
renderSystemMessage(data);
break;
case 'welcome':
renderWelcomeMessage(data);
break;
case 'typing':
renderTypingIndicator(data);
break;
default:
// Fallback: treat as plain text chat
renderChatMessage({ username: 'unknown', content: data.content || data, timestamp: Date.now() });
}
}
function renderChatMessage(data) {
const msgDiv = document.createElement('div');
msgDiv.className = 'message chat';
if (data.username === username) {
msgDiv.classList.add('own');
}
const time = new Date(data.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
msgDiv.innerHTML = `
${data.username !== username ? `${escapeHtml(data.username)}` : ''}
${escapeHtml(data.content)}
${time}
`;
messagesArea.appendChild(msgDiv);
scrollToBottom();
}
function renderSystemMessage(data) {
const msgDiv = document.createElement('div');
msgDiv.className = 'message system';
msgDiv.innerHTML = `${escapeHtml(data.content)}`;
messagesArea.appendChild(msgDiv);
scrollToBottom();
}
function renderWelcomeMessage(data) {
// Show the welcome text as a system message
const msgDiv = document.createElement('div');
msgDiv.className = 'message system';
msgDiv.innerHTML = `🎉 ${escapeHtml(data.content)}`;
if (data.users && data.users.length > 0) {
msgDiv.innerHTML += `
Online: ${escapeHtml(data.users.join(', '))}`;
}
messagesArea.appendChild(msgDiv);
scrollToBottom();
}
function renderTypingIndicator(data) {
if (data.username === username) return; // Don't show own typing
if (data.isTyping) {
typingIndicatorEl.textContent = `${data.username} is typing...`;
} else {
typingIndicatorEl.textContent = '';
// Small delay to avoid flickering if another user starts typing immediately
setTimeout(() => {
if (typingIndicatorEl.textContent === `${data.username} is typing...`) {
typingIndicatorEl.textContent = '';
}
}, 100);
}
}
function scrollToBottom() {
messagesArea.scrollTop = messagesArea.scrollHeight;
}
// Utility to prevent XSS
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
Complete Client-Side Code
For reference, here is the complete <script> block that goes into the HTML file. All the pieces above combine into this single cohesive client:
<script>
let username = null;
let ws = null;
let typingTimeout = null;
let isTyping = false;
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}`;
function connect() {
ws = new WebSocket(wsUrl);
ws.onopen = () => {
console.log('Connected to WebSocket server');
document.getElementById('status').textContent = '🟢 Connected';
document.getElementById('sendBtn').disabled = false;
document.getElementById('messageInput').disabled = false;
username = prompt('Enter your username:') || 'Anonymous';
ws.send(username);
document.getElementById('status').textContent = `🟢 Chatting as ${username}`;
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
handleMessage(data);
};
ws.onclose = () => {
console.log('Disconnected');
document.getElementById('status').textContent = '🔴 Disconnected — reconnecting...';
document.getElementById('sendBtn').disabled = true;
document.getElementById('messageInput').disabled = true;
setTimeout(connect, 3000);
};
ws.onerror = (err) => {
console.error('WebSocket error:', err);
};
}
connect();
const messageInput = document.getElementById('messageInput');
const sendBtn = document.getElementById('sendBtn');
const messagesArea = document.getElementById('messages');
const typingIndicatorEl = document.getElementById('typingIndicator');
function sendMessage() {
const text = messageInput.value.trim();
if (!text || !ws || ws.readyState !== WebSocket.OPEN) return;
ws.send(JSON.stringify({ type: 'chat', content: text }));
messageInput.value = '';
if (isTyping) {
isTyping = false;
ws.send(JSON.stringify({ type: 'typing', isTyping: false }));
}
}
sendBtn.addEventListener('click', sendMessage);
messageInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); sendMessage(); }
});
messageInput.addEventListener('input', () => {
if (!ws || ws.readyState !== WebSocket.OPEN) return;
if (!isTyping) {
isTyping = true;
ws.send(JSON.stringify({ type: 'typing', isTyping: true }));
}
clearTimeout(typingTimeout);
typingTimeout = setTimeout(() => {
if (isTyping) {
isTyping = false;
ws.send(JSON.stringify({ type: 'typing', isTyping: false }));
}
}, 2000);
});
function handleMessage(data) {
switch (data.type) {
case 'chat': renderChatMessage(data); break;
case 'system': renderSystemMessage(data); break;
case 'welcome': renderWelcomeMessage(data); break;
case 'typing': renderTypingIndicator(data); break;
default: renderChatMessage({ username: 'unknown', content: data.content || data, timestamp: Date.now() });
}
}
function renderChatMessage(data) {
const msgDiv = document.createElement('div');
msgDiv.className = 'message chat';
if (data.username === username) msgDiv.classList.add('own');
const time = new Date(data.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
msgDiv.innerHTML = `
${data.username !== username ? `${escapeHtml(data.username)}` : ''}
${escapeHtml(data.content)}
${time}
`;
messagesArea.appendChild(msgDiv);
scrollToBottom();
}
function renderSystemMessage(data) {
const msgDiv = document.createElement('div');
msgDiv.className = 'message system';
msgDiv.innerHTML = `${escapeHtml(data.content)}`;
messagesArea.appendChild(msgDiv);
scrollToBottom();
}
function renderWelcomeMessage(data) {
const msgDiv = document.createElement('div');
msgDiv.className = 'message system';
msgDiv.innerHTML = `🎉 ${escapeHtml(data.content)}`;
if (data.users && data.users.length > 0) {
msgDiv.innerHTML += `
Online: ${escapeHtml(data.users.join(', '))}`;
}
messagesArea.appendChild(msgDiv);
scrollToBottom();
}
function renderTypingIndicator(data) {
if (data.username === username) return;
if (data.isTyping) {
typingIndicatorEl.textContent = `${data.username} is typing...`;
} else {
typingIndicatorEl.textContent = '';
setTimeout(() => {
if (typingIndicatorEl.textContent === `${data.username} is typing...`) {
typingIndicatorEl.textContent = '';
}
}, 100);
}
}
function scrollToBottom() {
messagesArea.scrollTop = messagesArea.scrollHeight;
}
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
</script>
Running the Application
With both files ready, start the server:
node server.js
You'll see output confirming the server is running:
Server running on http://localhost:3000
WebSocket server is ready to accept connections
Open http://localhost:3000 in multiple browser tabs. Each tab will prompt for a username, then connect to the chat. Messages typed in one tab appear instantly in all others. System notifications announce join/leave events. Typing indicators show when other users are composing messages.
Best Practices for WebSocket Chat Applications
Heartbeat / Ping-Pong for Connection Health
WebSocket connections can appear open even when the underlying TCP connection has silently failed (e.g., due to network changes, proxies timing out, or laptop sleep/wake cycles). Implementing ping-pong heartbeat checks detects dead connections and cleans them up.
// Add to server.js after wss creation
// Heartbeat: periodically ping all clients
const HEARTBEAT_INTERVAL = 30000; // 30 seconds
setInterval(() => {
for (const client of clients) {
if (client.isAlive === false) {
client.terminate();
clients.delete(client);
continue;
}
client.isAlive = false;
client.ping(); // ws library sends a WebSocket ping frame
}
}, HEARTBEAT_INTERVAL);
// On each new connection, set isAlive flag and handle pong responses
wss.on('connection', (ws) => {
ws.isAlive = true;
// The ws library automatically responds to pings with pongs,
// but we need to detect the pong to reset isAlive
ws.on('pong', () => {
ws.isAlive = true;
});
// ... rest of connection handler
});
This ensures dead connections are terminated promptly, keeping the client list accurate and preventing wasted broadcast attempts.
Handling Disconnects Gracefully
Always wrap send operations in readiness checks. A client may disconnect between the time