tests.ws

Socket.IO Guide - Real-Time with Node.js

socket.io real-time node.js websocket javascript

Socket.IO is a library that adds features on top of WebSocket: automatic reconnection, rooms, namespaces, and acknowledgements. It is not a WebSocket library. It uses its own protocol that falls back to WebSocket (or HTTP long-polling). A Socket.IO client cannot connect to a plain WebSocket server, and a plain WebSocket client cannot connect to a Socket.IO server. This distinction matters more than most developers realize, and understanding it will save you hours of debugging.

Socket.IO Is Not WebSocket

This point deserves its own section because it causes constant confusion. Socket.IO uses a custom protocol called Engine.IO under the hood. When a Socket.IO client connects to a server, the initial handshake happens over HTTP. The client and server exchange capability information, session IDs, and transport preferences. Only after this handshake does the connection upgrade to a WebSocket transport (if available).

The data sent over the wire is also different. Socket.IO wraps every message in its own packet format that includes packet types, namespace information, and acknowledgement IDs. If you open your browser’s Network tab and inspect a Socket.IO WebSocket frame, you will see prefixed numbers and encoded metadata that a plain WebSocket server would not understand.

This means:

  • You cannot point a browser’s new WebSocket() at a Socket.IO server and expect it to work.
  • You cannot point a Socket.IO client at a plain WebSocket server like ws and expect it to work.
  • If you need protocol-level compatibility with third-party WebSocket services, Socket.IO is the wrong choice.

When to use Socket.IO: You control both the client and server, you want built-in reconnection logic, you need rooms or namespaces, and you want a higher-level event API.

When to use plain WebSocket: You need raw protocol compatibility, minimal overhead, or you are connecting to a third-party WebSocket API. See the Node.js ws guide for that approach.

Installation

Socket.IO is split into two packages: one for the server and one for the client.

Server:

npm install socket.io

Client (for Node.js or bundler-based projects):

npm install socket.io-client

As of early 2025, the current major version is Socket.IO v4. All examples in this guide target v4.x. If you are migrating from v2 or v3, consult the official migration guide because there are breaking changes in how connections and CORS are handled.

For browser usage without a bundler, Socket.IO automatically serves a client script at /socket.io/socket.io.js when you attach it to your HTTP server.

Basic Server Setup

The most common pattern is attaching Socket.IO to an Express server. Here is a minimal working example:

const express = require("express");
const { createServer } = require("http");
const { Server } = require("socket.io");

const app = express();
const httpServer = createServer(app);
const io = new Server(httpServer, {
  cors: {
    origin: "http://localhost:3000",
    methods: ["GET", "POST"],
  },
});

io.on("connection", (socket) => {
  console.log("Client connected:", socket.id);

  socket.on("disconnect", (reason) => {
    console.log("Client disconnected:", socket.id, reason);
  });
});

httpServer.listen(4000, () => {
  console.log("Server running on port 4000");
});

A few things to note about this setup:

  1. You create a standard Node.js HTTP server with createServer(). Socket.IO attaches to this server, it does not replace it.
  2. The cors option is required if your client runs on a different origin. Without it, the browser will block the connection.
  3. Each connected client gets a unique socket.id. This ID changes on every reconnection.

You can also attach Socket.IO to servers created with the https module, or with frameworks like Fastify and Koa.

Basic Client Setup

In the browser (script tag):

If Socket.IO is attached to your server, the client library is served automatically:

<script src="/socket.io/socket.io.js"></script>
<script>
  const socket = io("http://localhost:4000");

  socket.on("connect", () => {
    console.log("Connected with ID:", socket.id);
  });
</script>

With a bundler (webpack, Vite, etc.):

import { io } from "socket.io-client";

const socket = io("http://localhost:4000");

socket.on("connect", () => {
  console.log("Connected with ID:", socket.id);
});

The io() function accepts the server URL as the first argument and an options object as the second. By default, the client will attempt to reconnect automatically if the connection drops.

Events

Socket.IO uses an event-driven model. You emit events on one side and listen for them on the other. This is the core of how you exchange data.

Sending and receiving custom events:

// Server
io.on("connection", (socket) => {
  socket.on("chat:message", (data) => {
    console.log("Received message:", data);
    socket.emit("chat:message:saved", { id: 1, text: data.text });
  });
});

// Client
socket.emit("chat:message", { text: "Hello, world!" });

socket.on("chat:message:saved", (data) => {
  console.log("Message saved with ID:", data.id);
});

You can send any JSON-serializable data as the payload: strings, numbers, objects, arrays, and even binary data (as Buffer or ArrayBuffer).

Built-in events:

Both client and server have built-in events you should handle:

  • connect / connection - Fires when a connection is established.
  • disconnect - Fires when the connection is lost.
  • connect_error - Fires on the client when a connection attempt fails.

Acknowledgements (callbacks):

Socket.IO supports request-response patterns through acknowledgements. The sender passes a callback function as the last argument to emit(), and the receiver calls it to send data back:

// Client sends a message and waits for confirmation
socket.emit("chat:message", { text: "Hello" }, (response) => {
  console.log("Server confirmed:", response.status);
});

// Server receives the message and responds via the callback
io.on("connection", (socket) => {
  socket.on("chat:message", (data, callback) => {
    // Save to database...
    callback({ status: "ok", id: 42 });
  });
});

This pattern eliminates the need to create separate “response” events for every action.

Rooms

Rooms are a server-side concept that lets you group sockets together. A socket can join multiple rooms, and you can broadcast messages to everyone in a room. This is ideal for features like chat channels, game lobbies, or per-user notification streams.

io.on("connection", (socket) => {
  // Join a room
  socket.on("room:join", (roomName) => {
    socket.join(roomName);
    console.log(socket.id, "joined room", roomName);

    // Notify others in the room
    socket.to(roomName).emit("room:user-joined", {
      userId: socket.id,
    });
  });

  // Leave a room
  socket.on("room:leave", (roomName) => {
    socket.leave(roomName);
    socket.to(roomName).emit("room:user-left", {
      userId: socket.id,
    });
  });

  // Send a message to a specific room
  socket.on("room:message", ({ roomName, text }) => {
    io.to(roomName).emit("room:message", {
      from: socket.id,
      text,
    });
  });
});

Key details about rooms:

  • Rooms exist only on the server. The client does not know which rooms it belongs to unless you tell it.
  • Every socket automatically joins a room named after its own socket.id. This is how you can send messages to a specific client.
  • socket.to(room).emit() sends to everyone in the room except the sender. Use io.to(room).emit() to send to everyone including the sender.
  • When a socket disconnects, it is automatically removed from all rooms.

Namespaces

Namespaces let you split your application logic over separate communication channels on the same server. Each namespace has its own set of event handlers, rooms, and middleware. The default namespace is /.

A practical example: your application has a chat feature and a notifications feature. Instead of prefixing every event name, you create two namespaces:

const chatNamespace = io.of("/chat");
const notificationsNamespace = io.of("/notifications");

chatNamespace.on("connection", (socket) => {
  console.log("User connected to chat:", socket.id);

  socket.on("message", (data) => {
    chatNamespace.emit("message", data);
  });
});

notificationsNamespace.on("connection", (socket) => {
  console.log("User connected to notifications:", socket.id);

  // Send pending notifications on connect
  socket.emit("pending", getPendingNotifications(socket.handshake.auth.userId));
});

On the client, you connect to a specific namespace by appending the path:

const chatSocket = io("http://localhost:4000/chat");
const notifSocket = io("http://localhost:4000/notifications");

chatSocket.on("message", (data) => {
  renderChatMessage(data);
});

notifSocket.on("pending", (notifications) => {
  renderNotifications(notifications);
});

Each namespace connection goes through its own handshake, so a client can connect to one namespace without connecting to another.

Broadcasting

Socket.IO gives you several ways to control who receives a message:

io.on("connection", (socket) => {
  // Send to ALL connected clients (including sender)
  io.emit("announcement", "Server restarting in 5 minutes");

  // Send to all clients EXCEPT the sender
  socket.broadcast.emit("user:joined", socket.id);

  // Send to all clients in a specific room (including sender)
  io.to("room-1").emit("room:update", { count: 5 });

  // Send to all clients in a room EXCEPT the sender
  socket.to("room-1").emit("room:update", { count: 5 });

  // Send to a specific client by their socket ID
  io.to(targetSocketId).emit("private:message", { text: "Hello" });

  // Send to multiple rooms at once
  io.to("room-1").to("room-2").emit("shared:update", data);
});

The difference between io.emit() and socket.broadcast.emit() is important. The first sends to every connected socket. The second sends to every socket except the one that triggered the event.

Middleware

Socket.IO supports middleware functions that run before a connection is fully established. This is the right place to handle authentication.

Server-level middleware (runs for all namespaces):

io.use((socket, next) => {
  const token = socket.handshake.auth.token;

  if (!token) {
    return next(new Error("Authentication required"));
  }

  try {
    const user = verifyJWT(token);
    socket.data.user = user;
    next();
  } catch (err) {
    next(new Error("Invalid token"));
  }
});

Per-namespace middleware:

const adminNamespace = io.of("/admin");

adminNamespace.use((socket, next) => {
  if (socket.data.user && socket.data.user.role === "admin") {
    next();
  } else {
    next(new Error("Admin access required"));
  }
});

On the client, you pass auth data in the connection options:

const socket = io("http://localhost:4000", {
  auth: {
    token: "your-jwt-token-here",
  },
});

socket.on("connect_error", (err) => {
  console.log("Connection failed:", err.message);
  // "Authentication required" or "Invalid token"
});

Middleware errors cause a connect_error event on the client. The connection is not established, and the client will retry according to its reconnection settings.

Error Handling

Handling errors properly is critical for real-time applications. Socket.IO provides several mechanisms for this.

Connection errors on the client:

const socket = io("http://localhost:4000", {
  reconnection: true,
  reconnectionAttempts: 10,
  reconnectionDelay: 1000,
  reconnectionDelayMax: 5000,
});

socket.on("connect_error", (err) => {
  console.log("Connection error:", err.message);
  // Show a "reconnecting..." banner to the user
});

socket.on("disconnect", (reason) => {
  console.log("Disconnected:", reason);

  if (reason === "io server disconnect") {
    // The server forcefully disconnected the client.
    // You must manually reconnect.
    socket.connect();
  }
  // Otherwise, the client will try to reconnect automatically.
});

Common disconnect reasons include:

  • "transport close" - The connection was lost (network issue, server crash).
  • "ping timeout" - The server did not respond to a heartbeat.
  • "io server disconnect" - The server called socket.disconnect(). Automatic reconnection will not happen in this case.
  • "io client disconnect" - The client called socket.disconnect().

Server-side error handling:

io.on("connection", (socket) => {
  socket.on("data:update", async (data, callback) => {
    try {
      const result = await updateDatabase(data);
      callback({ status: "ok", result });
    } catch (err) {
      console.error("Update failed:", err);
      callback({ status: "error", message: "Update failed" });
    }
  });
});

Always wrap async operations in try/catch blocks. An unhandled promise rejection in a socket event handler will not crash Socket.IO, but the client will never receive a response if you are using acknowledgements.

Scaling with Redis Adapter

A single Socket.IO server keeps all connection state in memory. If you run multiple server instances behind a load balancer, clients connected to different instances cannot communicate with each other by default. The Redis adapter solves this problem.

npm install @socket.io/redis-adapter redis
const { createClient } = require("redis");
const { createAdapter } = require("@socket.io/redis-adapter");

const pubClient = createClient({ url: "redis://localhost:6379" });
const subClient = pubClient.duplicate();

async function startServer() {
  await Promise.all([pubClient.connect(), subClient.connect()]);

  io.adapter(createAdapter(pubClient, subClient));

  httpServer.listen(4000, () => {
    console.log("Server running on port 4000");
  });
}

startServer();

The adapter uses Redis pub/sub to broadcast events across all server instances. When you call io.to("room-1").emit("update", data) on one server, the adapter publishes the event through Redis, and all other instances deliver it to their local clients in that room.

Sticky sessions are required. Because the Socket.IO handshake involves multiple HTTP requests before upgrading to WebSocket, all requests from a single client must reach the same server instance. Configure your load balancer (Nginx, HAProxy, AWS ALB) to use IP-based or cookie-based sticky sessions.

For more on scaling WebSocket applications, read the WebSocket scalability guide.

Socket.IO vs Plain WebSocket

FeatureSocket.IOPlain WebSocket
ProtocolCustom (Engine.IO)Standard RFC 6455
Auto reconnectionBuilt-inYou must implement it
RoomsBuilt-inYou must implement it
NamespacesBuilt-inNot available
AcknowledgementsBuilt-inYou must implement it
Binary supportYesYes
HTTP long-polling fallbackYesNo
Browser supportWider (via fallback)Modern browsers only
Client bundle size~45 KB (minified + gzip)0 KB (native API)
Protocol overheadHigher (packet framing)Lower
Third-party compatibilitySocket.IO clients onlyAny WebSocket client/server

The bundle size difference matters if you are building a lightweight application. The native JavaScript WebSocket API adds zero bytes to your bundle. Socket.IO’s client library adds roughly 45 KB. For most applications this is acceptable, but for embedded widgets or bandwidth-constrained environments, it adds up.

When to Use Socket.IO

Socket.IO is a good fit when:

  • You control both the client and server code.
  • You need rooms, namespaces, or acknowledgements without building them yourself.
  • You want automatic reconnection with configurable retry logic.
  • You need to support older browsers or restrictive network environments where WebSocket is blocked (the HTTP fallback handles this).
  • You plan to scale horizontally and want an adapter system (Redis, MongoDB, Postgres) to coordinate multiple server instances.

Plain WebSocket is a better fit when:

  • You are connecting to a third-party WebSocket API (financial data feeds, multiplayer game servers, etc.).
  • You need minimal protocol overhead for high-frequency data (like sensor data or real-time audio).
  • You want to keep your client-side bundle as small as possible.
  • You are building a service that other developers will connect to. Using Socket.IO forces all consumers to use the Socket.IO client library.

You can also test both protocols using an interactive WebSocket testing tool to see how they behave in practice.

  • Node.js WebSocket Server with ws - Build a WebSocket server using the ws library, the most popular plain WebSocket implementation for Node.js.
  • JavaScript WebSocket Client Guide - Learn the browser’s native WebSocket API, which works with any standards-compliant server.
  • WebSocket Scalability - Patterns for scaling persistent connections across multiple servers, load balancers, and cloud environments.
  • What Is WebSocket? - A foundational overview of the WebSocket protocol, how the handshake works, and where it fits in the networking stack.
  • WebSocket Tester - Test WebSocket connections interactively in your browser.