Real-time Communication

Socket.IO

Socket.IO is a real-time event framework on top of WebSockets. It adds rooms, acknowledgements, automatic reconnection and a polling fallback, so you spend your time on features instead of connection plumbing.

intermediate14 min readUpdated Sep 16, 2026
server.ts
ts
// server.ts
import { Server } from "socket.io";

const io = new Server(httpServer, {
  cors: { origin: "https://app.example.com" },
});

io.use((socket, next) => {
  const user = verifyToken(socket.handshake.auth.token);
  if (!user) return next(new Error("unauthorized"));
  socket.data.user = user;
  next();
});

io.on("connection", (socket) => {
  socket.join(`user:${socket.data.user.id}`);

  socket.on("message:send", (payload, ack) => {
    io.to(payload.room).emit("message:new", payload);
    ack({ ok: true });
  });
});
Released
2010
Runs on
Node.js
Transport
WebSocket with polling fallback
Protocol
Engine.IO
Scale with
Redis adapter
Client
Browser and Node.js

Why it matters

What Socket.IO gives you on day one

Rooms and namespaces

Group sockets server-side and emit to a room, a namespace or everyone except the sender. No manual connection bookkeeping, and it scales across instances.

Reconnection is built in

The client reconnects with backoff after a dropped connection and can recover missed packets, so mobile networks and laptop sleep do not break the app.

Events with acknowledgements

Every emit can carry a callback, so request/response over a socket feels like a function call instead of a guessing game.

The big picture

The three layers of Socket.IO

An event layer for your application, a transport layer that starts as HTTP polling, and an adapter layer that scales across instances.

Events

Emit

Everything is a named event. The server and client emit and listen on the same channel names, and payloads are plain JSON.

Transport

Upgrade

The connection starts as HTTP long polling and upgrades to WebSocket, so it survives proxies, old browsers and hostile networks.

Adapter

Scale

An adapter broadcasts events between server instances, which is what lets many pods serve one logical real-time application.

At a glance

The pieces you will use

Events

socket.emit and socket.on move named payloads in both directions.

Rooms

socket.join(room) and io.to(room).emit(...) target a subset.

Namespaces

Split one connection into isolated channels like /chat and /admin.

Reconnection

Automatic backoff reconnect plus optional state recovery.

Acknowledgements

A callback confirms the server received and handled an event.

Redis adapter

Share broadcasts between instances and keep sticky sessions.

Flow

A real-time message

From handshake to disconnect, every Socket.IO interaction follows the same short path.

  1. 1

    Connect

    The client opens a connection and presents its credentials in the handshake auth payload.

  2. 2

    Join rooms

    The server assigns the socket to rooms based on the authenticated user, the tenant and any subscriptions.

  3. 3

    Emit

    A client emits a named event with a JSON payload and, optionally, a callback for the reply.

  4. 4

    Broadcast

    The server handles the event and emits to the relevant room, so every interested socket receives it.

  5. 5

    Disconnect

    When the connection closes, the socket leaves all of its rooms automatically and the server cleans up.

The complete guide

Socket.IO: Everything you need to know

What Socket.IO adds to WebSockets

Socket.IO is a library for real-time, event-based communication between a server and its clients. Underneath, it uses the WebSocket protocol when it can and falls back to HTTP long polling when it cannot. On top of that transport it gives you a small application protocol: named events, rooms, acknowledgements, automatic reconnection and heartbeats.

That extra layer is the whole point. A raw WebSocket connection gives you a pipe and nothing else. Every real application then has to build the same things again: how do I group connections by chat room or tenant, how do I know a message was received, how do I reconnect cleanly, how do I broadcast across several servers? Socket.IO answers those questions once, in a well-tested library, so you can spend your time on the product.

The trade-off is that Socket.IO is not a plain WebSocket. It defines its own handshake and packet format on top of Engine.IO, so a native WebSocket client cannot connect to a Socket.IO server. Both sides must use the Socket.IO client. If you need interoperability with arbitrary WebSocket clients, use the WebSockets guide and the ws library instead.

The rest of this guide assumes you have met the raw protocol and now want the batteries-included version.

Server and client setup

The server attaches to an existing HTTP server, which is usually the same one serving your API. That means one port, one TLS certificate and no extra infrastructure.

import { Server } from "socket.io";
import { createServer } from "node:http";

const httpServer = createServer(app);

const io = new Server(httpServer, {
  cors: { origin: process.env.APP_ORIGIN, credentials: true },
});

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

httpServer.listen(3000);

The client connects with the same origin and authenticates through the handshake.

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

const socket = io("https://api.example.com", {
  auth: { token: getAccessToken() },
  withCredentials: true,
});

socket.on("connect", () => console.log("connected", socket.id));
socket.on("disconnect", (reason) => console.log("closed", reason));

The socket.id is a per-connection identifier. It is useful for logging and for addressing one specific connection, but it changes on reconnect, so never use it as a user id or store it as durable state.

Events, acknowledgements and callbacks

Everything in Socket.IO is a named event carrying a JSON payload. The server and client both call emit to send and on to listen. Names are just strings, so pick a convention and keep it — noun:verb such as message:send, message:new and presence:joined reads well on both sides.

The feature that distinguishes Socket.IO from a bare socket is the acknowledgement. If the emitter passes a callback as the last argument, the receiver can call it to reply, turning the emit into a request/response over the same connection.

// client
socket.emit("message:send", { room: "general", body: "hello" }, (ack) => {
  if (!ack.ok) showError(ack.error);
});

// server
socket.on("message:send", (payload, ack) => {
  if (!payload.body) return ack({ ok: false, error: "empty" });
  io.to(payload.room).emit("message:new", payload);
  ack({ ok: true, at: Date.now() });
});

Use acknowledgements for any event whose outcome the client needs to know: creating a record, joining a room, submitting a form. For pure broadcasts where nobody is waiting for a reply, plain emit is fine. A middle ground is socket.timeout(5000).emit(...), which fails the callback if no acknowledgement arrives in time.

Middleware and the event pipeline

Socket.IO has two middleware layers, and putting logic in the right one keeps handlers clean.

Connection middleware, registered with io.use or namespace.use, runs once per connection before the connection event. It is where authentication, tenant resolution and per-connection setup belong. Middleware runs in registration order, and each one calls next() to continue or next(new Error(...)) to reject.

io.use((socket, next) => {
  const startedAt = Date.now();

  socket.on("disconnect", () => {
    metrics.observe("socket.duration", Date.now() - startedAt);
  });

  next();
});

Event middleware, registered with socket.use, runs for every incoming event on that socket. It is the natural home for validation, rate limiting and structured logging, because it sees the event name and payload before any handler does.

const chat = io.of("/chat");

chat.use((socket, next) => {
  if (!socket.data.user) return next(new Error("unauthorized"));
  next();
});

chat.use((socket, next) => {
  socket.onAny((event, ...args) => {
    logger.info({ event, userId: socket.data.user.id, args });
  });
  next();
});

socket.onAny observes every incoming event, and socket.onAnyOutgoing observes everything you send, which together give you a complete trace of a connection without touching a single handler. Middleware is also namespace-aware: the /admin namespace can demand a different role without affecting /chat.

Rooms and namespaces

Two grouping mechanisms keep messages targeted instead of broadcast to everyone.

A room is a server-side label on a set of sockets. Any socket can join or leave any room at any time, and a socket can be in many rooms at once. When you emit to a room, only its members receive the event.

io.on("connection", (socket) => {
  socket.join(`user:${socket.data.user.id}`);

  socket.on("room:join", (room, ack) => {
    socket.join(room);
    ack({ ok: true });
  });
});

A namespace is a separate communication channel under a path, such as /chat or /admin. Namespaces have their own middleware, their own connection handlers and their own rooms. Use namespaces to separate concerns that should not share events at all — a public chat namespace and an internal admin namespace — rather than to model data within one feature.

Rooms are the workhorse. Model them after the things your users care about: a conversation, a document, a tenant, a dashboard. Then broadcasting becomes a one-liner instead of a loop over connections.

Broadcasting and targeting

Socket.IO has a small vocabulary for who receives an event, and getting it right prevents both data leaks and wasted fan-out.

  • io.emit(...) — every connected socket. Rarely what you want.
  • socket.emit(...) — only the socket handling the current event.
  • socket.broadcast.emit(...) — everyone except the sender.
  • socket.to(room).emit(...) — everyone in the room except the sender.
  • io.to(room).emit(...) — everyone in the room, including the sender.
  • io.to(roomA).to(roomB).emit(...) — the union of both rooms.
  • socket.to(socketId).emit(...) — one specific socket by id.
socket.on("typing", ({ room }) => {
  // Tell the room, but not the person typing.
  socket.to(room).emit("typing", { user: socket.data.user.id });
});

Chaining to unions recipients; there is no intersection operator in the core API. If you need “members of room A who are also admins”, model it as a room of its own — room:${id}:admins — rather than trying to compute it at emit time.

Authenticating the handshake

Authentication belongs in the connection handshake, not in a first message. Socket.IO middleware registered with io.use runs before the connection event, so you can reject an unauthenticated socket before it can emit or join anything.

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

  try {
    const payload = verifyToken(token);
    socket.data.user = { id: payload.sub, roles: payload.roles };
    next();
  } catch {
    next(new Error("unauthorized"));
  }
});

Two details matter. First, socket.data is the place to attach per-connection state; it survives for the life of the connection and is available in every handler. Second, a rejected connection sends a connect_error event on the client, so the UI can prompt for a fresh token instead of retrying forever.

For browser clients, a cookie sent with the handshake is an alternative to an explicit token, and it lets you reuse existing session infrastructure. Either way, always set the cors origin to your own application, and authorise each event against the user in socket.data — authentication proves who connected, not what they are allowed to do.

Reconnection and connection state recovery

Connections drop. Laptops sleep, phones switch networks, load balancers recycle. Socket.IO reconnects automatically with exponential backoff and jitter, and it emits reconnect_attempt and reconnect events so you can show the right UI.

By default, though, events sent while the client was away are lost. The client reconnects as a new socket and resumes from now. That is acceptable for a live feed but wrong for a chat or a collaborative document.

Connection state recovery solves the short-gap case. Enable it on the server, and a reconnecting client that presents its session id receives the packets it missed, as long as the disconnection was brief.

const io = new Server(httpServer, {
  connectionStateRecovery: {
    maxDisconnectionDuration: 2 * 60 * 1000,
    skipMiddlewares: true,
  },
});

Recovery is deliberately bounded: it holds recent packets in memory for a short window and only on the instance that saw the original connection, so it is not a substitute for durable storage. Anything that must survive a longer outage — messages, orders, document edits — belongs in a database, with the socket used only to notify clients that something changed.

Scaling with the Redis adapter

A Socket.IO server keeps its sockets and rooms in memory. Run two instances behind a load balancer and they are effectively two separate real-time applications: a message emitted on instance A never reaches clients on instance B.

The Redis adapter fixes this. Every instance publishes its broadcasts to a Redis pub/sub channel and subscribes to the same channel, so an emit on one instance is delivered to matching sockets everywhere.

import { createAdapter } from "@socket.io/redis-adapter";
import { createClient } from "redis";

const pubClient = createClient({ url: process.env.REDIS_URL });
const subClient = pubClient.duplicate();

await Promise.all([pubClient.connect(), subClient.connect()]);

const io = new Server(httpServer, {
  adapter: createAdapter(pubClient, subClient),
});

Two operational notes. First, sticky sessions are still required during the HTTP polling phase, because the Engine.IO handshake spans several requests that must reach the same instance. Configure the load balancer for cookie-based affinity, or force the WebSocket transport only. Second, the adapter uses Redis pub/sub, which is fast but not durable: a message published while an instance is restarting is missed. Redis is also covered in its own Redis guide.

For very large deployments there is a sharded adapter that spreads channels across a Redis cluster, and adapters for other brokers, but start with the standard one.

Emitting from outside a socket

Real-time events rarely originate inside a connection handler. An HTTP request creates a comment, a worker finishes a report, a webhook confirms a payment — all of these need to notify connected clients.

Keep a reference to io and emit to a room from anywhere:

app.post("/comments", async (req, res) => {
  const comment = await db.comment.create({ data: req.body });

  io.to(`post:${comment.postId}`).emit("comment:new", comment);

  res.status(201).json(comment);
});

Rooms are the right tool here because they are shared across instances when the adapter is configured. To reach one specific user, emit to a per-user room — user:${id} — that the socket joined at connection time, rather than tracking socket ids. That keeps working when the user has two tabs open, reconnects or lands on a different instance.

If you do need a socket id, io.in(room).fetchSockets() returns the live sockets in a room, which is useful for presence counts and targeted sends.

Presence, typing and collaborative features

Rooms plus a shared store cover most collaborative features. Presence — who is online — is the common one, and the naive version breaks the moment you run more than one instance.

The pattern is to keep authoritative presence in Redis, not in a JavaScript map, and to clean up on disconnect:

io.on("connection", async (socket) => {
  const { id } = socket.data.user;
  await redis.sadd("online", id);

  socket.on("disconnect", async () => {
    const sockets = await io.in(`user:${id}`).fetchSockets();
    if (sockets.length === 0) await redis.srem("online", id);
  });
});

The fetchSockets check matters: a user with two tabs should not appear offline when one tab closes. Typing indicators, cursor positions and “someone is editing” flags follow the same shape — ephemeral state broadcast to a room, with a short expiry so a crashed client does not leave a stale indicator forever.

For true collaborative editing with conflict resolution, reach for a CRDT library such as Yjs and send its updates over the socket, rather than inventing a merge algorithm.

Validation, rate limiting and safety

A socket is an untrusted input channel exactly like an HTTP request. Treat every event payload as hostile until validated.

  • Validate shapes. Parse payloads with a schema library like Zod before touching them, and reject with an acknowledgement instead of throwing.
  • Rate limit. A socket can emit thousands of events per second. Use a per-socket token bucket and disconnect or throttle abusers.
  • Cap payload size. maxHttpBufferSize bounds how much a single message can carry.
  • Authorise every event. Re-check that the user in socket.data may act on the room or resource, not just that they are connected.
  • Validate the origin. Set cors.origin and do not leave it open in production.
  • Keep timeouts. Heartbeats and idle timeouts stop abandoned connections from leaking.
io.on("connection", (socket) => {
  socket.use(([event, payload], next) => {
    const parsed = MessageSchema.safeParse(payload);
    if (!parsed.success) return next(new Error("invalid_payload"));
    if (!takeToken(socket.id)) return next(new Error("rate_limited"));
    next();
  });
});

Per-socket middleware registered with socket.use is the clean place to centralise these checks, so individual handlers stay focused on business logic.

Debugging and observability

The first tool is built in. Setting DEBUG=socket.io:* (or engine*) prints the handshake, transport upgrades and packet flow, which is usually enough to diagnose a client that will not connect.

For production, track the signals that actually predict incidents:

  • Connected sockets — a sudden drop means a deploy, a crash or a network partition.
  • Events per second, by name — a spike on one event is often a runaway client loop.
  • Disconnect reasonsping timeout and transport close point at network or proxy problems, while io server disconnect means your code closed the socket.
  • Adapter health — if Redis pub/sub is slow or disconnected, cross-instance broadcasts stop without any socket appearing to fail.
io.on("connection", (socket) => {
  socket.on("disconnect", (reason) => {
    metrics.increment("socket.disconnect", { reason });
  });
});

The @socket.io/admin-ui package adds a dashboard over the same data, and it is worth running in staging so you can see rooms and sockets as they change. As with any real-time system, the most confusing failures are partial: one instance is fine, another is not, and only a per-instance view reveals it.

Testing a real-time server

Real-time code is testable. Start the server on an ephemeral port, connect a few socket.io-client instances and assert on the events they receive. The important discipline is to await events rather than sleep, so tests are fast and deterministic.

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

test("broadcasts a message to the room", async () => {
  const a = Client(url, { auth: { token } });
  const b = Client(url, { auth: { token } });

  await Promise.all([once(a, "connect"), once(b, "connect")]);

  a.emit("room:join", "r1");
  b.emit("room:join", "r1");

  const received = once(b, "message:new");
  a.emit("message:send", { room: "r1", body: "hi" });

  const [message] = await received;
  expect(message.body).toBe("hi");

  a.close();
  b.close();
});

Test the failure paths too, because they are where real-time bugs live: an unauthenticated client should receive connect_error, a bad payload should be rejected by an acknowledgement, and a socket that disconnects should leave its rooms. Use a unique room or namespace per test so parallel tests cannot see each other’s events, and always close the clients so the test process exits.

Deploying a Socket.IO server

A Socket.IO deployment is an HTTP deployment with two extra requirements: long-lived connections and shared state. Most of the surprises come from forgetting one of them.

  • One port. Attach Socket.IO to the same HTTP server as your API and terminate TLS at the proxy. There is no separate port to expose.
  • Proxy support for upgrades. Nginx and most load balancers need explicit configuration to pass the Upgrade and Connection headers; without it the connection silently stays on polling.
  • Sticky sessions. Cookie-based affinity keeps the polling handshake on one instance. If you force the WebSocket-only transport, affinity matters less, but the handshake still has to complete somewhere.
  • Redis adapter. Configure it before the second instance exists, not after users report missing messages.
  • Graceful shutdown. On SIGTERM, stop accepting connections and close the server so in-flight events finish.
process.on("SIGTERM", async () => {
  io.close(); // disconnects clients and stops the server
  await pubClient.quit();
  await subClient.quit();
  httpServer.close();
});
upstream io_nodes {
  ip_hash;
  server 10.0.0.1:3000;
  server 10.0.0.2:3000;
}

Size each instance for the connections it holds, not just for request throughput. Every socket costs memory and a file descriptor, and a single instance with tens of thousands of connections will fail in ways a request-based service never does. Scale out early, watch connection counts, and give deploys a grace period long enough for clients to reconnect to a healthy instance.

When raw WebSockets are the better choice

Socket.IO is not always the answer. Choose the raw protocol when:

  • Interoperability matters. Native WebSocket clients, other languages and strict protocol tooling cannot speak Socket.IO’s custom handshake.
  • You need the smallest possible client. Socket.IO ships a client bundle; a raw WebSocket is built into the browser.
  • Your infrastructure is WebSocket-native. Some gateways, brokers and edge runtimes terminate WebSockets but not Socket.IO’s polling fallback.
  • You control both ends and want no abstraction. If rooms and reconnection are trivial for your use case, ws is less to reason about.

Conversely, choose Socket.IO when you want rooms, acknowledgements, automatic reconnection and multi-instance broadcasting without building them. For most product teams, that list is the entire feature set of their real-time layer, which is exactly why the library exists.

Best practices

  • Authenticate in io.use and store the user in socket.data, not in a closure.
  • Model rooms after real domain objects — conversations, documents, tenants.
  • Use acknowledgements for events whose result the client needs.
  • Prefer socket.to(room) when the sender should not receive their own event.
  • Enable connection state recovery, but keep durable state in a database.
  • Add the Redis adapter before you add a second instance, and configure sticky sessions.
  • Emit from HTTP handlers and workers through rooms, not stored socket ids.
  • Validate, authorise and rate limit every event.
  • Clean up presence in disconnect and use fetchSockets to handle multiple tabs.
  • Set maxHttpBufferSize, CORS origins and timeouts explicitly.

Common mistakes

  • Calling io.emit when only a room should receive the event.
  • Assuming Socket.IO and WebSockets are interchangeable, then failing to connect a native client.
  • Trusting socket.handshake.auth without verifying the token.
  • Storing presence in a local Map and losing it behind a load balancer.
  • Forgetting sticky sessions and breaking the polling handshake.
  • Expecting reconnection to replay events without connection state recovery.
  • Using socket.id as a user id, then breaking on reconnect.
  • Doing heavy or blocking work inside an event handler and stalling every socket on the instance.
  • Leaving payload validation and rate limiting to the frontend.
  • Treating the socket as durable storage for anything that must not be lost.

Where to go next

Socket.IO is the high-level layer over the protocol covered in the WebSockets guide, which explains frames, heartbeats and the upgrade handshake you are now abstracting. The Redis guide goes deeper on the pub/sub and shared state behind the adapter, and Node.js Basics explains the event loop that every handler runs on. If your Socket.IO server is attached to an HTTP API, the Express guide covers the routing and middleware it shares a process with.

In practice

Server, client, rooms and scaling

The four files behind most real-time features.

server.ts
import { Server } from "socket.io";
import { createServer } from "node:http";

const httpServer = createServer(app);

const io = new Server(httpServer, {
  cors: {
    origin: process.env.APP_ORIGIN,
    credentials: true,
  },
});

io.use((socket, next) => {
  const token = socket.handshake.auth.token;
  try {
    socket.data.user = verifyToken(token);
    next();
  } catch {
    next(new Error("unauthorized"));
  }
});

io.on("connection", (socket) => {
  socket.join(`user:${socket.data.user.id}`);
  socket.emit("ready", { id: socket.id });
});

httpServer.listen(3000);

Socket.IO rooms vs raw WebSocket channels

Socket.IO tracks membership for you. With the raw protocol you maintain a map of connections and fan out by hand, which is easy to get subtly wrong.

Socket.IO
socket.join(`org:${orgId}`);
io.to(`org:${orgId}`).emit("update", payload);
Raw WebSocket
// You own membership, fan-out and cleanup.
const rooms = new Map<string, Set<WebSocket>>();
for (const ws of rooms.get(`org:${orgId}`) ?? []) {
  if (ws.readyState === ws.OPEN) ws.send(payload);
}

Acknowledgement vs fire-and-forget emit

A callback turns an emit into a request/response and surfaces failures. Fire-and-forget is fine for pure broadcasts, but not for actions that can be rejected.

With ack
socket.emit("order:create", order, (result) => {
  if (!result.ok) showError(result.error);
  else markCreated(result.id);
});
Fire and forget
socket.emit("order:create", order);
// No idea whether the server accepted,
// rejected or crashed.

Trade-offs

Is Socket.IO the right abstraction?

Socket.IO trades a small protocol layer and a required client for a large amount of real-time plumbing you would otherwise write yourself.

Strengths

  • Real-time features, not plumbing

    Rooms, acknowledgements, reconnection, heartbeats and a polling fallback are all included. That is weeks of edge cases you do not have to discover in production.

  • One API on both sides

    The server and client share the same event model, so a feature is usually a few lines on each side. Onboarding a frontend developer takes minutes.

  • Scales across instances

    The Redis adapter turns a fleet of pods into one logical server for rooms and broadcasts, which is the hard part of any real-time deployment.

Trade-offs

  • It is not plain WebSocket

    Socket.IO defines its own protocol on top of Engine.IO. A native WebSocket client cannot connect, so you need the Socket.IO client on every platform.

  • Rooms live in memory

    Room membership is held per process unless you use an adapter. Without Redis, a reconnect on another instance can silently land in the wrong place.

  • Easy to over-broadcast

    io.emit sends to every connected socket. It is one keystroke away and can leak data or melt a large deployment, so target rooms deliberately.

FAQ

Frequently asked questions

Keep learning

Related topics from the roadmap.

$ start learning

Ready to start learning Socket.IO?

Our interactive tutorial walks you through Socket.IO step by step — with quizzes and real code you can run in the browser.