All Tutorials
TypeScript
Express
Socket.IO
WebSockets
real-time
backend

Build a Real-Time Chat App with Socket.IO, Express, and TypeScript

A code-along tutorial: build a real-time chat server with Socket.IO on top of Express and TypeScript. Messages sent in one browser tab appear instantly in another, no page refresh. Every file is here to paste and run, and it needs no paid API key.

12 min read2026-08-14

The short answer

Socket.IO adds real-time, two-way events on top of a normal Express HTTP server. You create an http.Server from your Express app, attach a Socket.IO Server to it, and listen for a custom `chat message` event. When one client emits that event, the server broadcasts it back out to every connected client with io.emit, so the message shows up in all open tabs at once with no refresh. This tutorial builds that in TypeScript with typed event payloads, plus a tiny static HTML page so you can test it in two browser tabs side by side. Two runtime dependencies, no paid API key, and about ten minutes of setup.

  • Setup time: about 10 minutes
  • Runtime dependencies: 2 (Express, Socket.IO)
  • No paid API key needed: free end to end
  • Stack: TypeScript, Express, Socket.IO, tsx
  • One event ('chat message'), broadcast to every connected client
  • Every file is in this page: paste into a fresh project and run

What you will build

By the end of this tutorial you will have a chat server where a message typed in one browser tab appears instantly in another, with no page refresh. The whole thing is one small Express server, one Socket.IO connection handler, and one static HTML page to test it. Open the page in two tabs, type in one, and watch the message land in the other in real time.

The interesting part is the transport. A normal REST API is request and response: the client asks, the server answers, the connection closes. A chat app needs the server to push a message to clients that did not ask for it, the moment it arrives. That is what a WebSocket gives you: a persistent, two-way connection that stays open so either side can send at any time. Socket.IO is the library that makes WebSockets pleasant to work with in Node.

Every file you need is in this page. Paste each one into a fresh project, follow the setup steps, and it runs. No repo to clone, no hidden files.

Why Socket.IO instead of the raw WebSocket API

Node has a low-level ws package and browsers have a built-in WebSocket object, so you can build real-time features without Socket.IO. For a production chat app, most teams still reach for Socket.IO anyway, and the reasons are practical.

Automatic reconnection. Networks drop. Laptops sleep. With the raw WebSocket API you write the reconnect-and-back-off logic yourself. Socket.IO does it for you, and buffers events emitted while the connection is down so they send once it is back.

Rooms and namespaces. Real chat apps have channels, direct messages, and groups. Socket.IO's rooms let you broadcast to a subset of clients with one line (io.to('room').emit(...)) instead of tracking which socket belongs to which conversation by hand.

Fallback transports. If a WebSocket connection cannot be established (a strict corporate proxy, an old browser), Socket.IO falls back to HTTP long-polling so the app keeps working. The raw API just fails.

Those three features are why Socket.IO is still the most-reached-for real-time library in the Node ecosystem. You pay for it with a slightly larger client bundle and a custom protocol on top of WebSockets, which is a fair trade for a chat app.

What you need

  • Node.js 18 or newer. Run node -v to check. Update at nodejs.org if you are behind.
  • A terminal and a text editor. Everything else installs with npm.
  • No paid API key. Unlike our AI chatbot tutorial, this one is genuinely free end to end. There is no external service to sign up for.

Setup

Create a fresh project and install the two runtime dependencies. Express handles the HTTP layer and serves the test page; socket.io is the real-time server. Socket.IO ships its own TypeScript types, so there is no separate @types/socket.io to install.

bash
mkdir realtime-chat
cd realtime-chat
npm init -y
npm install express socket.io
npm install -D typescript tsx @types/node @types/express

Add a tsconfig.json at the project root. These settings work with tsx in development and compile with tsc for a production build:

json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "rootDir": "src",
    "outDir": "dist"
  },
  "include": ["src"]
}

Then open package.json, add "type": "module", and set these scripts:

json
{
  "type": "module",
  "scripts": {
    "dev": "tsx src/server.ts",
    "build": "tsc",
    "start": "node dist/server.js"
  }
}

You will create two files: src/server.ts for the Socket.IO server, and public/index.html for the test client. Make both directories now:

bash
mkdir src public

How Socket.IO sits on top of Express

This is the one idea that trips people up, so it is worth stating plainly. Socket.IO does not replace Express. It attaches to the same underlying Node HTTP server that Express runs on.

Instead of calling app.listen(port) the way a plain Express app does, you create an http.Server from your Express app with createServer(app), then hand that server to both Express (which keeps handling normal HTTP routes) and Socket.IO (which handles the WebSocket upgrade). One server, one port, two jobs. Express serves your test page over HTTP; Socket.IO carries the live chat events over the persistent connection.

Communication happens through named events. A client calls socket.emit('chat message', text) to send, and the server listens with socket.on('chat message', handler). To push data the other way, the server emits and the client listens. You choose the event names; chat message is just the one this app uses.

The server

The whole backend is one file. Create src/server.ts:

typescript
import express from "express";
import { createServer } from "node:http";
import { Server, type Socket } from "socket.io";

// The events that flow in each direction, and the exact shape of their
// payloads. Typing them here means every emit and every handler is checked
// by the compiler, so a payload is never `any`.
interface ClientToServerEvents {
  "chat message": (text: string) => void;
}

interface ServerToClientEvents {
  "chat message": (payload: { text: string; sentAt: string }) => void;
}

const app = express();

// Socket.IO attaches to a Node http.Server, so build one from the Express app
// instead of calling app.listen. Express still serves normal HTTP routes;
// Socket.IO handles the WebSocket upgrade on the same server and port.
const httpServer = createServer(app);
const io = new Server<ClientToServerEvents, ServerToClientEvents>(httpServer);

// Serve the test client from public/. Socket.IO also serves its own browser
// bundle at /socket.io/socket.io.js automatically, so the page needs no build.
app.use(express.static("public"));

io.on(
  "connection",
  (socket: Socket<ClientToServerEvents, ServerToClientEvents>) => {
    console.log(`client connected: ${socket.id}`);

    socket.on("chat message", (text) => {
      if (typeof text !== "string" || text.trim() === "") return;
      // io.emit sends to every connected client, including the sender, so the
      // message appears in all open tabs at once.
      io.emit("chat message", {
        text: text.trim(),
        sentAt: new Date().toISOString(),
      });
    });

    socket.on("disconnect", () => {
      console.log(`client disconnected: ${socket.id}`);
    });
  },
);

const port = Number(process.env.PORT) || 3000;
httpServer.listen(port, () => {
  console.log(`Chat server listening on http://localhost:${port}`);
});

A few things worth noting.

The two interfaces are the contract. ClientToServerEvents and ServerToClientEvents are passed as generics to new Server<...>(). From then on, if you emit chat message with the wrong payload shape, or listen for an event that does not exist, TypeScript stops you at compile time. That is the whole reason to write this in TypeScript rather than plain JS.

`io.emit` broadcasts to everyone. When a message comes in, io.emit('chat message', ...) sends it to every connected client, the sender included. That is why the message you type shows up in your own tab too, not just the other one. If you wanted to send to everyone *except* the sender, you would use socket.broadcast.emit instead.

The guard matters. The if (typeof text !== 'string' || text.trim() === '') check drops empty or malformed messages before they broadcast. Never trust what a client sends, even in a demo.

A minimal test client

This is enough HTML to prove the server works in two tabs, not a frontend framework tutorial. One file, one input, one list. Create public/index.html:

html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>Socket.IO chat</title>
    <style>
      body { font-family: system-ui, sans-serif; max-width: 40rem; margin: 2rem auto; }
      #messages { list-style: none; padding: 0; }
      #messages li { padding: 0.4rem 0.6rem; border-bottom: 1px solid #eee; }
      form { display: flex; gap: 0.5rem; margin-top: 1rem; }
      input { flex: 1; padding: 0.5rem; }
    </style>
  </head>
  <body>
    <h1>Real-time chat</h1>
    <ul id="messages"></ul>
    <form id="form">
      <input id="input" autocomplete="off" placeholder="Type a message" />
      <button type="submit">Send</button>
    </form>

    <!-- Socket.IO serves this bundle for you; no CDN, no npm install. -->
    <script src="/socket.io/socket.io.js"></script>
    <script>
      const socket = io();
      const form = document.getElementById("form");
      const input = document.getElementById("input");
      const messages = document.getElementById("messages");

      form.addEventListener("submit", (event) => {
        event.preventDefault();
        if (!input.value.trim()) return;
        socket.emit("chat message", input.value);
        input.value = "";
      });

      socket.on("chat message", (payload) => {
        const li = document.createElement("li");
        li.textContent = payload.text;
        messages.appendChild(li);
      });
    </script>
  </body>
</html>

The <script src="/socket.io/socket.io.js"> line is doing quiet work. The Socket.IO server automatically serves its browser client at that path, so you do not install socket.io-client or pull a bundle from a CDN. io() with no arguments connects back to the same origin the page was served from. After that, the client emits and listens for the exact same event names the server uses.

Run it and test in two tabs

Start the server:

bash
npm run dev
# -> Chat server listening on http://localhost:3000

Now open http://localhost:3000 in two browser tabs, side by side. Type a message in the first tab and press Send. It appears in both tabs at the same instant, with no refresh. Type in the second tab and it shows up in the first. Your terminal logs a client connected line for each tab, and a client disconnected line when you close one.

That live, two-tab moment is the whole point. A static code snippet can show you the code, but it cannot show you a message crossing between two clients in real time. Seeing it happen is what makes the WebSocket model click.

Go deeper on the backend

Want the fundamentals under this, HTTP, servers, and JavaScript on the backend? The free Node.js courses we rank cover the ground this tutorial assumes, and the backend developer learn path sequences the free courses in order.

One real-world extension: scope messages to a room

This tutorial broadcasts every message to every connected client, which is exactly what a single global chat room needs and nothing more. A real app has separate channels or direct messages, and that is where Socket.IO rooms come in. A room is just a named group of sockets; you join one and then broadcast to only that room instead of everyone.

The change is small. When a client asks to join, call socket.join(room). Then send with io.to(room).emit(...) instead of io.emit(...):

typescript
// Client asks to join a named room.
socket.on("join room", (room: string) => {
  socket.join(room);
});

// Broadcast to only the clients in that room, not everyone.
io.to("general").emit("chat message", {
  text: "Welcome to #general",
  sentAt: new Date().toISOString(),
});

The honest limitation to keep in mind: this server holds no message history. When a client connects, it sees only messages sent from that point on, because nothing is stored. Refresh the page and the list is empty. A production chat app persists messages to a database (Postgres, or Redis for speed) and replays recent history to a client when it joins. That is the natural next thing to add, and it is deliberately out of scope here so the real-time mechanics stay in focus.

When you are ready to put a real backend behind this, our tutorial on building a REST API with Express and TypeScript covers the request/response side, and the JWT authentication tutorial shows how to tell which user a socket belongs to, which is the first thing you need before rooms mean anything.

Frequently asked questions

Socket.IO vs the raw WebSocket API: which should I use?

Use the raw WebSocket API (the browser's built-in WebSocket object, or the ws package on Node) when you want the smallest possible dependency and you are happy writing reconnection and message-buffering logic yourself. Use Socket.IO when you want automatic reconnection, rooms and namespaces for channels, and a fallback to HTTP long-polling when a WebSocket cannot connect. For a chat app those three features do real work, which is why Socket.IO is the common choice. The trade-off is a larger client bundle and a custom protocol layered on top of WebSockets.

Does this scale to production as-is?

No, and it is not meant to. The server keeps all connections in the memory of a single process, so it runs fine on one machine but does not span multiple servers. To scale horizontally you add sticky sessions at the load balancer (so a client keeps hitting the same server during the handshake) and the Socket.IO Redis adapter, which relays events between server instances so a message sent on one server reaches clients connected to another. You would also persist messages to a database instead of keeping them only in memory. Those are the standard next steps; this tutorial gives you the working single-server core they build on.

Why not use this WebSocket pattern for the AI chatbot tutorial?

Because they are different problems. A chat app between people is push-based: any client can send at any time, and the server pushes messages out to others, so a persistent two-way connection fits. Calling an LLM is request/response: you send a prompt, you wait, you get one answer back. A normal HTTP POST handles that cleanly, which is what our AI chatbot tutorial at /tutorials/build-ai-chatbot-express-typescript uses. WebSockets earn their place when the server needs to push unprompted, or when you want to stream tokens as they generate, not for a single round-trip.

How do I handle CORS if my client is on a different origin?

In this tutorial the client is served by the same Express server, so same-origin rules apply and there is nothing to configure. If your frontend runs on a different origin (say a React app on localhost:5173 talking to this server on localhost:3000), Socket.IO needs an explicit CORS setting. Pass it when you create the server: new Server(httpServer, { cors: { origin: 'http://localhost:5173' } }). Set origin to the exact origin(s) your client runs on rather than a wildcard once you go to production.

Do I need to install the socket.io-client package?

Not for this tutorial. The Socket.IO server automatically serves its browser client bundle at /socket.io/socket.io.js, and the test page loads it from there with a plain script tag, so io() is available with no install and no CDN. You install socket.io-client with npm only when you are importing the client into a bundled frontend (a React, Vue, or Svelte app) where you use import { io } from 'socket.io-client' instead of a script tag.

Why io.emit instead of socket.broadcast.emit?

io.emit sends the event to every connected client, including the one that sent the original message. socket.broadcast.emit sends to everyone except the sender. This tutorial uses io.emit so the message appears in the sender's own tab too, which keeps the client simple: it just renders whatever the server broadcasts and never has to echo its own messages locally. If you would rather show the sender's message immediately on send and only receive others' messages over the socket, switch to socket.broadcast.emit and append the sent message to the list in the client.

Keep going on FreeCodingCourses