Modern web applications often need to deliver updates instantly. Think about live chat messages, delivery tracking, collaborative dashboards, or notifications that appear without refreshing the page. Traditional HTTP requests work well for many tasks, but repeatedly polling a server for new information can introduce unnecessary latency and network overhead.
WebSockets solve this problem by establishing a persistent, two-way communication channel between a client and a server. Once connected, both sides can send messages whenever necessary instead of waiting for another HTTP request.
In this guide, we will build a practical real-time messaging application using Node.js, Express, Socket.IO, and React. You will learn how to establish connections, broadcast messages, handle disconnections, clean up React effects, and structure a foundation that you can extend into a production-ready application.
Building a Real-Time Application with Socket.IO
WebSockets provide full-duplex communication over a single TCP connection. After the initial HTTP-based handshake, the connection remains open, allowing the server and client to exchange data in either direction. This makes WebSockets useful for applications where updates need to arrive quickly and continuously.
For this example, we will use Socket.IO rather than implementing the WebSocket protocol directly. Socket.IO provides convenient event-based messaging, automatic reconnection, connection lifecycle events, and room-based broadcasting. It is important to remember that Socket.IO is its own protocol and is not directly interchangeable with a plain WebSocket client or server.
Our application will have two parts: a Node.js server and a React client. The server accepts connections, announces when users join or leave, and broadcasts messages to connected clients. The React component connects to the server, sends messages through an input field, displays incoming messages, and disconnects cleanly when the component unmounts.
Start by creating a project and installing the dependencies with npm install express socket.io cors for the server and npm install socket.io-client for the React application. Save the following server code as server.js, then run it with node server.js. The server listens on port 3001, while your React development server can run separately on port 3000.
const express = require("express");
const http = require("http");
const cors = require("cors");
const { Server } = require("socket.io");
// Step 1: Create an Express application for HTTP endpoints.
const app = express();
app.use(cors({ origin: "http://localhost:3000" }));
app.use(express.json());
// Step 2: Create an HTTP server that Socket.IO can attach to.
const httpServer = http.createServer(app);
// Step 3: Configure Socket.IO and explicitly allow the React origin.
const io = new Server(httpServer, {
cors: {
origin: "http://localhost:3000",
methods: ["GET", "POST"],
},
});
// Step 4: Keep a simple in-memory list of connected users.
// Production applications should use a shared store when needed.
const connectedUsers = new Map();
// Step 5: Expose a basic health-check endpoint.
app.get("/health", (req, res) => {
res.json({ status: "ok", service: "realtime-chat" });
});
// Step 6: Handle each new Socket.IO connection.
io.on("connection", (socket) => {
console.log(`[connect] Client connected: ${socket.id}`);
// Register a display name supplied by the client.
socket.on("user:join", (payload) => {
const name =
typeof payload?.name === "string"
? payload.name.trim().slice(0, 40)
: "";
// Ignore invalid names instead of storing empty identities.
if (!name) {
socket.emit("chat:error", { message: "Enter a valid display name." });
return;
}
connectedUsers.set(socket.id, name);
console.log(`[join] ${name} joined the chat`);
// Notify every connected client about the updated user list.
io.emit("users:update", Array.from(connectedUsers.values()));
// Notify other clients without echoing the event to the sender.
socket.broadcast.emit("chat:system", {
text: `${name} joined the chat`,
timestamp: new Date().toISOString(),
});
});
// Step 7: Receive, validate, and broadcast chat messages.
socket.on("chat:message", (payload) => {
const name = connectedUsers.get(socket.id);
const text =
typeof payload?.text === "string" ? payload.text.trim() : "";
// Require a registered user and a non-empty message.
if (!name || !text) {
socket.emit("chat:error", {
message: "Join the chat and enter a message first.",
});
return;
}
// Limit message size to keep individual payloads manageable.
if (text.length > 1000) {
socket.emit("chat:error", {
message: "Messages must contain at most 1000 characters.",
});
return;
}
// Build a consistent message object for all clients.
const message = {
id: `${socket.id}-${Date.now()}`,
name,
text,
timestamp: new Date().toISOString(),
};
console.log(`[message] ${name}: ${text}`);
// Broadcast the message to every connected client, including sender.
io.emit("chat:message", message);
});
// Step 8: Clean up state when a client disconnects.
socket.on("disconnect", (reason) => {
const name = connectedUsers.get(socket.id);
connectedUsers.delete(socket.id);
console.log(`[disconnect] ${socket.id}: ${reason}`);
if (name) {
io.emit("chat:system", {
text: `${name} left the chat`,
timestamp: new Date().toISOString(),
});
// Keep the displayed user list synchronized.
io.emit("users:update", Array.from(connectedUsers.values()));
}
});
});
// Step 9: Start listening only after the server is configured.
const PORT = process.env.PORT || 3001;
httpServer.listen(PORT, () => {
console.log(`[startup] Chat server listening on port ${PORT}`);
console.log(`[startup] Health check: http://localhost:${PORT}/health`);
});
Conclusion
WebSockets make real-time features more responsive by eliminating the need for continuous polling. With Node.js, Express, Socket.IO, and React, you can build a messaging foundation that supports instant updates, user presence, and connection lifecycle management.
However, a working local example is only the starting point. Before deploying it, add authentication, validate user identities on the server, enforce rate limits, configure allowed origins through environment variables, and use HTTPS. Never trust a client-provided display name as proof of identity, and avoid relying on in-memory state when running multiple server instances.
For larger applications, consider Socket.IO rooms for private conversations, Redis-backed adapters for distributing events across instances, persistent database storage for chat history, and acknowledgement callbacks for important messages. With these pieces in place, the same architecture can support live notifications, collaborative tools, customer support systems, and real-time dashboards.
What real-time feature would you build first with WebSockets: a chat application, a live dashboard, or collaborative editing?
Top comments (0)