Build a Simple Realtime Chat App with Node.js and WebSockets

Build a Simple Realtime Chat App with Node.js and WebSockets

July 16, 2026

Realtime chat still matters in 2026 because users continue to expect instant feedback in product support, collaboration tools, gaming, trading dashboards, customer success portals, and internal operations software. For many of these use cases, the key requirement is not just “fresh data,” but low-latency, bidirectional communication. WebSockets fit that requirement well: they establish a persistent connection between browser and server so either side can send messages whenever needed, instead of waiting for a request-response cycle. The browser-side WebSocket API is widely available and stable, making it a practical default when you want broad compatibility and a simple mental model. (developer.mozilla.org)

In this tutorial, we’ll build a minimal realtime chat application with a Node.js backend and a browser client. We’ll cover the protocol basics, server and client code, reliability concerns, scaling patterns, and debugging techniques. The goal is not to produce a production-grade messenger in one sitting, but to show the architecture and implementation choices that matter when you move from “hello world” sockets to something maintainable. The examples will use the standard browser WebSocket API and a Node.js server that handles upgrade requests over HTTP. (nodejs.org)

Architecture overview of a simple realtime chat app

1. Introduction: Why Realtime Chat Still Matters in 2026

Chat remains one of the clearest examples of a realtime system because the UX expectation is immediate delivery, immediate visibility, and immediate response. Even when your product is not “a chat app,” chat-like behavior appears in notifications, live support widgets, multiplayer coordination, collaborative editing side channels, and internal command-and-control panels. The value of realtime is not novelty; it is reducing the time between an event and the user seeing it. In many products, that time reduction directly improves conversion, support resolution, or operational efficiency. WebSockets are a strong fit here because they keep a single connection open and allow messages to flow both directions with low overhead once the connection is established. (developer.mozilla.org)

WebSockets sit in a useful middle ground. Compared with polling, they avoid repeated HTTP requests and the waste of checking for updates when nothing has changed. Compared with long polling, they are cleaner for continuous interaction and easier to reason about once you understand the connection lifecycle. Compared with more specialized transports, WebSockets are simpler and much more broadly supported in browsers. MDN describes the standard WebSocket interface as stable and broadly available, and notes that if standard WebSocket connections fit your use case and you want wide browser compatibility, they are the quickest path forward. (developer.mozilla.org)

That said, WebSockets are not the only realtime option. Server-Sent Events are a good fit for one-way updates from server to browser, but not for bidirectional chat. WebTransport offers modern features such as backpressure and additional transport patterns, but its support is not as broad and it is more complex. MDN also notes that WebSocketStream can address backpressure, but it is experimental and non-standard. For a simple browser chat app in 2026, standard WebSockets remain the pragmatic choice unless you have specialized transport needs. (developer.mozilla.org)

2. Core Concepts: How WebSockets Work

WebSockets begin life as ordinary HTTP requests. The browser sends an HTTP request with an Upgrade: websocket header, and the server responds by switching protocols if it accepts the handshake. Node.js exposes this through the HTTP server’s 'upgrade' event; by default, upgrade requests are ignored unless you attach a handler. Once upgraded, the connection is no longer typical HTTP request/response traffic — it becomes a persistent duplex stream between client and server. (nodejs.org)

At the protocol level, RFC 6455 defines the WebSocket Protocol and the opening handshake that establishes the connection. The key point is that the initial HTTP exchange is just a gatekeeper. After the handshake succeeds, messages travel over a persistent TCP-based connection framed as WebSocket messages rather than independent HTTP requests. This is what makes chat efficient: you pay the setup cost once, then reuse the connection for many messages. (ietf.org)

On the browser side, the API is straightforward: create a WebSocket, listen for open, message, error, and close, and send data with .send(). MDN notes that the WebSocket interface is well established and widely supported across browsers since July 2015. The tradeoff is that the standard API does not provide built-in backpressure; if incoming messages outpace processing, the browser can buffer aggressively and potentially consume memory or CPU. That is one reason to keep message payloads small and the client logic simple. (developer.mozilla.org)

const socket = new WebSocket("ws://localhost:8080");

socket.addEventListener("open", () => {
  console.log("Connected");
  socket.send(JSON.stringify({ type: "join", username: "alex" }));
});

socket.addEventListener("message", (event) => {
  console.log("Received:", event.data);
});

For a basic chat app, these primitives are enough: open a connection, identify the user, send messages, and broadcast messages to everyone else. The rest of the application is just good engineering around this core. The browser-side API is stable enough to use directly in production for standard realtime interactions, while more advanced streaming options are best reserved for cases where their complexity is justified. (developer.mozilla.org)

Message flow from browser to Node.js server and back

3. Project Setup: Node.js App, Dependencies, and Folder Structure

A clean setup makes the rest of the implementation much easier. Start with a small Node.js project and separate the server code from the browser client. Even if the app is tiny, that separation prevents the common “everything in one file” trap that makes realtime code hard to evolve. Node.js has built-in HTTP support, and the 'upgrade' event gives you the hook needed to intercept WebSocket handshakes. In practice, developers usually pair the HTTP server with a WebSocket library such as ws, which is a simple and widely used Node.js WebSocket library. (nodejs.org)

A minimal folder structure might look like this:

chat-app/
  server/
    index.js
    chat-room.js
    message-format.js
  client/
    index.html
    app.js
    styles.css
  package.json

You can initialize the project with npm init -y, then install the dependencies you need. For a lean chat app, ws is enough for the WebSocket server and client-side Node utilities if you use them. If you want a development server with static file hosting, you can serve the client directly from Node, or keep it as static files served separately. The important thing is to keep the client build simple so you can focus on the websocket lifecycle rather than build tooling. The ws project describes itself as a simple, fast, thoroughly tested WebSocket client and server for Node.js. (github.com)

A practical starting point:

mkdir chat-app
cd chat-app
npm init -y
npm install ws

In package.json, make your app runnable with a script:

{
  "name": "chat-app",
  "version": "1.0.0",
  "main": "server/index.js",
  "scripts": {
    "start": "node server/index.js"
  }
}

For local development, keep the client code small and dependency-free. A plain HTML page with a single input, a send button, and a message list is enough. This gives you a clean baseline for testing the protocol and the UX before you add frameworks, authentication, or message persistence. By keeping the structure clear from the beginning, you make later features like rooms, history, and presence much easier to integrate. (github.com)

4. Building the WebSocket Server

The server’s job is to accept socket connections, track connected clients, normalize message format, and broadcast messages to the appropriate audience. In a minimal chat room, “appropriate audience” means every connected client. A simple in-memory collection is enough for the first version: store each connected socket in a Set, add it on connection, and remove it on disconnect. Because the underlying transport is persistent, you don’t repeatedly authenticate or negotiate on every message unless your app requires that. (nodejs.org)

Here is a straightforward Node.js server using ws and the built-in HTTP server:

// server/index.js
const http = require("http");
const WebSocket = require("ws");

const server = http.createServer();
const wss = new WebSocket.Server({ server });

const clients = new Set();

function formatMessage({ type, username, text }) {
  return JSON.stringify({
    type,
    username,
    text,
    timestamp: new Date().toISOString()
  });
}

function broadcast(payload, exceptSocket = null) {
  for (const client of clients) {
    if (client.readyState === WebSocket.OPEN && client !== exceptSocket) {
      client.send(payload);
    }
  }
}

wss.on("connection", (socket) => {
  clients.add(socket);
  socket.username = "Anonymous";

  socket.send(formatMessage({
    type: "system",
    username: "system",
    text: "Welcome to the chat!"
  }));

  broadcast(formatMessage({
    type: "system",
    username: "system",
    text: `${socket.username} joined the chat`
  }), socket);

  socket.on("message", (raw) => {
    let data;

    try {
      data = JSON.parse(raw.toString());
    } catch {
      socket.send(formatMessage({
        type: "error",
        username: "system",
        text: "Invalid message format"
      }));
      return;
    }

    if (data.type === "join" && typeof data.username === "string") {
      socket.username = data.username.trim().slice(0, 30) || "Anonymous";
      socket.send(formatMessage({
        type: "system",
        username: "system",
        text: `You are now known as ${socket.username}`
      }));
      return;
    }

    if (data.type === "chat" && typeof data.text === "string") {
      const message = formatMessage({
        type: "chat",
        username: socket.username,
        text: data.text.trim().slice(0, 500)
      });
      broadcast(message);
    }
  });

  socket.on("close", () => {
    clients.delete(socket);
    broadcast(formatMessage({
      type: "system",
      username: "system",
      text: `${socket.username} left the chat`
    }));
  });
});

server.listen(8080, () => {
  console.log("Chat server listening on http://localhost:8080");
});

This version uses a single broadcast helper and a message format function so that all messages look consistent. That consistency matters more than it first appears: once clients know every payload has type, username, text, and timestamp, you can extend the protocol without turning the client into a pile of conditionals. The server also trims and bounds user input, which is an early form of safety and predictability. The ws README and MDN guidance both align with this event-driven approach to message handling. (github.com)

A small but important design choice is how you manage usernames. For a first pass, store a username directly on the socket object. That is simple and works well for a single-process server. Later, if you move to authentication or shared state, you can replace that with user IDs and session metadata without changing the basic connection model. The main goal now is to keep the connection handler understandable and the message protocol explicit. (nodejs.org)

5. Building the Chat Client

The client’s job is to connect, show messages, and let the user send messages without making the UI feel fragile. Because the browser WebSocket API is standardized and widely supported, the client can be very small. You create a socket, register event handlers, and update the DOM when a message arrives. When the connection opens, you can send a “join” message so the server knows the display name. MDN documents the core open, message, close, and error events you need for this flow. (developer.mozilla.org)

A basic index.html might look like this:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>Realtime Chat</title>
    <link rel="stylesheet" href="styles.css" />
  </head>
  <body>
    <main>
      <ul id="messages"></ul>
      <form id="chat-form">
        <input id="username" placeholder="Username" />
        <input id="message" placeholder="Type a message..." autocomplete="off" />
        <button type="submit">Send</button>
      </form>
      <p id="status">Connecting...</p>
    </main>
    <script src="app.js"></script>
  </body>
</html>

And the browser logic:

// client/app.js
const messagesEl = document.getElementById("messages");
const form = document.getElementById("chat-form");
const usernameInput = document.getElementById("username");
const messageInput = document.getElementById("message");
const statusEl = document.getElementById("status");

const socket = new WebSocket(`ws://${window.location.host}`);

function renderMessage({ type, username, text, timestamp }) {
  const li = document.createElement("li");
  li.className = type;

  const time = new Date(timestamp).toLocaleTimeString();
  li.textContent =
    type === "chat"
      ? `[${time}] ${username}: ${text}`
      : `[${time}] ${text}`;

  messagesEl.appendChild(li);
  messagesEl.scrollTop = messagesEl.scrollHeight;
}

socket.addEventListener("open", () => {
  statusEl.textContent = "Connected";
});

socket.addEventListener("message", (event) => {
  const data = JSON.parse(event.data);
  renderMessage(data);
});

socket.addEventListener("close", () => {
  statusEl.textContent = "Disconnected";
});

socket.addEventListener("error", () => {
  statusEl.textContent = "Connection error";
});

form.addEventListener("submit", (event) => {
  event.preventDefault();

  const username = usernameInput.value.trim();
  const text = messageInput.value.trim();

  if (!username || !text) return;

  socket.send(JSON.stringify({ type: "join", username }));
  socket.send(JSON.stringify({ type: "chat", text }));

  messageInput.value = "";
  messageInput.focus();
});

This is intentionally simple, but it demonstrates the core pattern: keep network state separate from render state, and let socket events update the UI directly. For responsiveness, avoid expensive DOM work in the message handler. Append only what you need, scroll intentionally, and keep message formatting predictable. Because the browser API is event-driven, it integrates cleanly with plain JavaScript without requiring a framework. (developer.mozilla.org)

6. Realtime UX Features

Once the basic chat works, the user experience becomes the next competitive differentiator. Small realtime touches make the app feel alive: typing indicators, join and leave notifications, timestamps, and stable auto-scroll behavior. These are not just cosmetic. They help users understand who is present, what is happening, and whether the system is behaving as expected. In a chat interface, ambiguity is friction. A well-designed realtime UI reduces that friction by making state visible. (developer.mozilla.org)

Typing indicators are a good example. The client can send a lightweight typing event when the input changes, and the server can broadcast that status to everyone else. Keep it ephemeral — don’t treat it like durable chat history. A simple implementation can debounce typing notifications to avoid flooding the socket. Similarly, join/leave notifications are often emitted by the server when connections open and close, which makes room activity visible without extra client logic. That works naturally in a WebSocket server because connect and disconnect are first-class lifecycle events. (developer.mozilla.org)

Timestamps are also valuable. If every message includes an ISO timestamp from the server, the client can render local time without guessing. Server-side timestamps are especially useful if you later add persistence or multiple backend instances. For auto-scroll, only force the chat window to the bottom when the user is already near the bottom; otherwise you risk yanking the view away while they are reading older messages. That nuance matters in active rooms. The WebSocket client lifecycle itself should be managed carefully as well: MDN notes that open WebSocket connections may affect the browser back-forward cache, so it is good practice to close them when the page is no longer in use. (developer.mozilla.org)

Realtime UX feature set for chat

Message persistence is the first feature that starts to feel “real app” rather than “demo.” Even basic persistence can be simple: write chat messages to a database or append them to a log, then load recent history on join. You do not need a full event-sourcing architecture on day one. But you do need a stable shape for messages, because once history is stored, the client and server both depend on that schema. Designing the message envelope early makes persistence a low-risk extension instead of a rewrite. (developer.mozilla.org)

7. Reliability and Safety

A chat app that works on localhost is not automatically safe or reliable in the real world. The first requirement is transport security: use HTTPS in production and connect with wss:// rather than ws://. WebSocket handshakes ride over HTTP semantics initially, so if your site is served securely, the socket should be secured as well. That protects credentials, message content, and session metadata from being exposed in transit. For browser clients, secure deployment is the default expectation, not an optional hardening step. (ietf.org)

You also need error handling and reconnect logic. If the network drops, the server restarts, or a proxy closes the connection, the client should not just stop silently. Common patterns include exponential backoff reconnects, visible connection state in the UI, and message queuing for unsent drafts. The standard WebSocket API exposes close and error events, which gives you the hooks to detect failures and retry appropriately. MDN also notes the lack of built-in backpressure in the standard API, which means you should keep payloads small and avoid flooding the client with unnecessary messages. (developer.mozilla.org)

Input validation is another non-negotiable. Treat all incoming data as untrusted, even if it came from your own client. Validate message types, bound string lengths, normalize usernames, and reject malformed JSON. For chat, moderation basics often start with simple controls: rate limiting, profanity filters, message length caps, room permissions, and the ability to disconnect abusive users. If you add HTML rendering, sanitize content rigorously and prefer text nodes over innerHTML to reduce injection risk. The WebSocket protocol itself does not solve these application-layer concerns; you have to build them deliberately. (developer.mozilla.org)

Backpressure deserves special attention once traffic grows. MDN explicitly warns that standard WebSocket connections do not provide backpressure, which means a fast producer can overwhelm a slower consumer. In a chat app, that usually shows up as a flood of presence events, typing notifications, or large room broadcasts. Mitigations include throttling nonessential events, batching updates, dropping stale typing indicators, and keeping message formats compact. If your app truly needs stream-level flow control, WebSocketStream or WebTransport may be more appropriate, but those options trade away simplicity and broad compatibility. (developer.mozilla.org)

8. Scaling the App

A single in-memory chat room is fine for a demo, but production chat usually needs rooms, user segmentation, and horizontal scaling. Rooms let you partition conversations by topic, project, or tenant. Namespaces provide a clean way to isolate different application domains, such as support chat versus internal ops. These concepts are implementation choices on top of the WebSocket transport, not part of the protocol itself, so you can model them however your architecture prefers. The key is to maintain a clear mapping from socket connection to logical audience. (ietf.org)

Horizontal scaling is where many naive implementations break down. If each server process keeps its own list of connected clients, a message sent to one instance will not magically reach users connected to another. In distributed deployments, you typically introduce a pub/sub layer or a dedicated adapter so instances can relay events across the cluster. This is one reason many teams adopt libraries or frameworks that already solve cross-instance delivery. While the underlying WebSocket protocol remains the same, the routing and fan-out layer becomes the real scaling challenge. (github.com)

Load balancers and proxies matter too. WebSocket traffic is long-lived, so you need infrastructure that supports upgraded connections and respects idle timeouts. If you deploy behind a reverse proxy, verify that it forwards Upgrade headers correctly and does not buffer frames in unexpected ways. Sticky sessions are sometimes used, but they are not a substitute for real shared state; they only keep a connection on the same upstream node. For a robust system, think in terms of stateless app servers, shared pub/sub infrastructure, and durable storage for history and presence when needed. Node’s HTTP 'upgrade' handling and the persistent nature of the connection make this deployment model a natural extension of the basic app. (nodejs.org)

9. Testing and Debugging

Testing WebSocket apps is different from testing standard HTTP APIs because the unit of interaction is a live connection, not a single request. Start by validating the handshake: confirm that the client reaches open, the server sees the connection event, and a message can make the full round trip. Then test failure paths: invalid JSON, unexpected message types, abrupt disconnects, and reconnect behavior. You want confidence not only that the happy path works, but that the app degrades predictably when the network is unstable. (developer.mozilla.org)

For browser debugging, open DevTools and inspect the Network tab. WebSocket frames are typically visible there, which lets you confirm payload contents, message timing, and connection lifetime. If a message is not rendering, determine whether the issue is transport, parsing, or DOM manipulation. That workflow is especially helpful when the server is sending data but the client is dropping it due to JSON parsing errors or schema mismatches. Because the standard API reports error and close events, you should log both with enough context to diagnose failures. (developer.mozilla.org)

When simulating multiple users, use multiple browser windows or tabs, or write a small scripted client to connect several sockets at once. This helps expose race conditions in join/leave messages and broadcast timing. It also reveals whether the UI can keep up with a burst of messages. If messages pile up faster than they can be rendered, the lack of standard WebSocket backpressure becomes visible quickly. MDN’s warning on buffering and CPU usage is not theoretical; it shows up under load if you don’t throttle or simplify event traffic. (developer.mozilla.org)

Common issues include mixed-content blocking when using ws:// on a secure page, reverse proxies dropping upgrade headers, and serialization mismatches between client and server. Another frequent problem is failing to clean up old socket references after disconnect, which leads to broadcasting to dead connections. A disciplined approach to logging, lifecycle cleanup, and message validation will solve most of these issues before they become production incidents. (nodejs.org)

10. Next Steps and Enhancements

Once the basic chat app works, the next features depend on your product goals. Authentication is usually the first real upgrade: you want to know who the user is, what rooms they can access, and whether messages should be attributed to a verified identity. After that, message history becomes important so new users can catch up. Presence indicators, read receipts, file sharing, moderation tooling, and per-room permissions are all natural extensions of the same socket-driven architecture. The WebSocket connection gives you the transport; your application protocol and data model define the product. (ietf.org)

From a technology perspective, it is also worth knowing your migration path. If your app grows into a more complex realtime system with transport features like stream backpressure, unidirectional channels, or unreliable datagrams, WebTransport may eventually become attractive. MDN notes that WebTransport provides capabilities beyond standard WebSockets but with less mature support and more complexity. If you want a higher-level abstraction with broader convenience features, Socket.IO is another common path in Node ecosystems, especially when you want fallbacks and room semantics out of the box. The right choice depends on whether you value protocol simplicity, feature richness, or operational convenience. (developer.mozilla.org)

Conclusion

A simple realtime chat app is one of the best ways to learn WebSockets because it forces you to understand the entire lifecycle: HTTP upgrade, persistent connection, message protocol, broadcast logic, client rendering, and operational concerns. In 2026, WebSockets remain the most practical default for broad-browser, low-latency bidirectional messaging. They are stable, widely supported, and straightforward to implement in Node.js with a small amount of code. (developer.mozilla.org)

The main takeaways are simple:

  • Use WebSockets when you need persistent, two-way, low-latency communication.

  • Keep your message format explicit and consistent from the start.

  • Treat reliability and safety as part of the core design, not as afterthoughts.

  • Plan for scaling early if more than one server instance will need to broadcast messages.

  • Revisit newer transports only when the standard WebSocket model no longer fits your requirements. (developer.mozilla.org)

References