Skip to content

Node.js7 min read

WebSockets in Node.js and React: Real-Time Apps Guide

Build real-time apps with WebSockets in Node.js and React: ws vs Socket.IO, rooms, auth, reconnection and scaling across servers with Redis. Typed code inside.

To build a real-time app with WebSockets in Node.js and React, run a WebSocket server in Node (with the ws library for a thin, standards-only socket, or Socket.IO for rooms, acknowledgements and auto-reconnect), connect from React inside a useEffect that registers listeners and cleans them up, and push events from the server the moment data changes. The hard parts are not the first connection. They are reconnection, rejoining state after a drop, and broadcasting across more than one server.

I learned most of this on a Node.js real-time bidding system for a Hong Kong company (Swivt), where every bid had to reach every connected user instantly, and later on the private Teen Patti rooms in Dashain Games. This guide is the version I would hand to a teammate starting a similar feature today.

Key takeaways

  • WebSockets give you one long-lived, two-way connection, so the server can push without the client polling.
  • ws is minimal and fast; Socket.IO (4.8.x at the time of writing) adds rooms, acks, reconnection and a fallback transport.
  • In React, keep one socket per app, not per component, and always remove listeners in the effect cleanup.
  • Treat every reconnect as a fresh session: re-authenticate, rejoin rooms and resync state.
  • Scaling past one Node process needs an adapter (Redis is the common choice) and often sticky sessions.

ws vs Socket.IO: which one to pick

Both are solid. They solve different amounts of the problem.

ws Socket.IO
Protocol Plain WebSocket (RFC 6455) Custom protocol over WebSocket or HTTP long-polling
Browser client Native WebSocket socket.io-client required
Rooms / namespaces Build it yourself Built in
Acknowledgements Build it yourself Built in (callbacks)
Auto-reconnect No Yes, with backoff
Multi-server broadcast Build it yourself (Redis pub/sub etc.) Adapters (Redis, Redis Streams, MongoDB, Postgres)
Overhead Lowest Small extra framing per message

My rule: if the feature is "a few clients receive a stream of events", ws is plenty. If users join and leave groups, need delivery confirmation and use mobile networks that drop constantly, Socket.IO saves weeks.

A minimal WebSocket server with ws

Here is a ws server that broadcasts messages and detects dead connections with ping/pong. Without the heartbeat, a phone that loses signal can sit in your client list for a long time because TCP never told you it left.

TypeScript
// server-ws.ts
import { WebSocketServer, WebSocket } from "ws";

type LiveSocket = WebSocket & { isAlive?: boolean };

const wss = new WebSocketServer({ port: 8080 });

wss.on("connection", (ws: LiveSocket, req) => {
  ws.isAlive = true;
  ws.on("pong", () => (ws.isAlive = true));
  ws.on("error", console.error);

  ws.on("message", (data, isBinary) => {
    for (const client of wss.clients) {
      if (client.readyState === WebSocket.OPEN) {
        client.send(data, { binary: isBinary });
      }
    }
  });

  console.log("connected from", req.socket.remoteAddress);
});

const interval = setInterval(() => {
  for (const client of wss.clients as Set<LiveSocket>) {
    if (client.isAlive === false) {
      client.terminate();
      continue;
    }
    client.isAlive = false;
    client.ping();
  }
}, 30_000);

wss.on("close", () => clearInterval(interval));

This pattern comes straight from the ws README. Note terminate() rather than close(): a dead peer will never complete the closing handshake.

Socket.IO server with rooms and auth

For most product features I reach for Socket.IO. Typing the events up front catches a whole class of bugs where the client and server disagree on payload shape.

TypeScript
// shared/events.ts
export type Bid = { auctionId: string; userId: string; amount: number; at: number };

export interface ServerToClientEvents {
  "bid:new": (bid: Bid) => void;
  "auction:state": (state: { highest: Bid | null }) => void;
}

export interface ClientToServerEvents {
  "auction:join": (auctionId: string, ack: (ok: boolean) => void) => void;
  "bid:place": (
    input: { auctionId: string; amount: number },
    ack: (res: { ok: true } | { ok: false; reason: string }) => void
  ) => void;
}
TypeScript
// server.ts
import { createServer } from "node:http";
import { Server } from "socket.io";
import type { ClientToServerEvents, ServerToClientEvents } from "./shared/events";
import { verifyToken, placeBid, getAuctionState } from "./domain";

type SocketData = { userId: string };

const httpServer = createServer();
const io = new Server<ClientToServerEvents, ServerToClientEvents, {}, SocketData>(httpServer, {
  cors: { origin: ["http://localhost:5173"] },
  connectionStateRecovery: { maxDisconnectionDuration: 2 * 60 * 1000 },
});

// Runs once per connection, before "connection"
io.use(async (socket, next) => {
  try {
    const user = await verifyToken(socket.handshake.auth.token);
    socket.data.userId = user.id;
    next();
  } catch {
    next(new Error("unauthorized"));
  }
});

io.on("connection", (socket) => {
  socket.on("auction:join", async (auctionId, ack) => {
    await socket.join(`auction:${auctionId}`);
    socket.emit("auction:state", await getAuctionState(auctionId));
    ack(true);
  });

  socket.on("bid:place", async ({ auctionId, amount }, ack) => {
    const result = await placeBid(auctionId, socket.data.userId, amount);
    if (!result.ok) return ack(result);
    io.to(`auction:${auctionId}`).emit("bid:new", result.bid);
    ack({ ok: true });
  });
});

httpServer.listen(3001);

A few decisions in there are deliberate:

  • Validation happens on the server. placeBid checks the amount against the current highest bid in the database. The socket is just transport; never trust the client's idea of state.
  • Acks give the bidder feedback. The person who bid gets a direct success or rejection, while everyone in the room gets bid:new.
  • Rooms are cheap. A room is just a server-side set of socket IDs, so one room per auction, chat or game table is normal.

Connecting from React

Create the socket once in a module, not inside a component. React StrictMode runs effects twice in development, and a socket created in render would open duplicate connections.

TypeScript
// lib/socket.ts
import { io, type Socket } from "socket.io-client";
import type { ClientToServerEvents, ServerToClientEvents } from "../shared/events";

export const socket: Socket<ServerToClientEvents, ClientToServerEvents> = io(
  import.meta.env.VITE_SOCKET_URL,
  {
    autoConnect: false,
    auth: (cb) => cb({ token: localStorage.getItem("token") }),
  }
);

Passing auth as a function means a fresh token is read on every reconnect, which matters when tokens expire during a long session.

TSX
// useAuction.ts
import { useEffect, useState } from "react";
import { socket } from "./lib/socket";
import type { Bid } from "./shared/events";

export function useAuction(auctionId: string) {
  const [connected, setConnected] = useState(socket.connected);
  const [bids, setBids] = useState<Bid[]>([]);

  useEffect(() => {
    function onConnect() {
      setConnected(true);
      // Runs on first connect AND every reconnect, so rooms are always rejoined
      socket.emit("auction:join", auctionId, () => {});
    }
    function onDisconnect() {
      setConnected(false);
    }
    function onBid(bid: Bid) {
      setBids((prev) => [bid, ...prev].slice(0, 50));
    }

    socket.on("connect", onConnect);
    socket.on("disconnect", onDisconnect);
    socket.on("bid:new", onBid);
    socket.connect();

    return () => {
      socket.off("connect", onConnect);
      socket.off("disconnect", onDisconnect);
      socket.off("bid:new", onBid);
      socket.disconnect();
    };
  }, [auctionId]);

  function placeBid(amount: number) {
    return socket.timeout(5000).emitWithAck("bid:place", { auctionId, amount });
  }

  return { connected, bids, placeBid };
}

The functional update setBids((prev) => ...) is important. Listeners are registered once per effect run, so reading bids directly would capture a stale value. If this pattern feels familiar, it is the same discipline from custom React hooks and from avoiding common useEffect mistakes.

emitWithAck returns a promise, and .timeout(5000) rejects it if the server does not answer, so your UI can show "bid not confirmed" instead of spinning forever.

WebSocket reconnection done properly

Socket.IO reconnects automatically. The native browser WebSocket does not, so with ws you write it yourself. Use exponential backoff with jitter so a server restart does not get hit by every client at the same millisecond.

TypeScript
// reconnecting-socket.ts
export function connect(url: string, onMessage: (data: unknown) => void) {
  let attempt = 0;
  let ws: WebSocket;
  let closedByUser = false;

  function open() {
    ws = new WebSocket(url);
    ws.onopen = () => (attempt = 0);
    ws.onmessage = (e) => onMessage(JSON.parse(e.data));
    ws.onclose = () => {
      if (closedByUser) return;
      const delay = Math.min(30_000, 500 * 2 ** attempt) * (0.5 + Math.random());
      attempt++;
      setTimeout(open, delay);
    };
  }

  open();
  return () => {
    closedByUser = true;
    ws.close();
  };
}

Reconnecting the socket is only half the job. The other half is state:

  • Missed events. Anything emitted while the client was offline is gone unless you replay it. Socket.IO's connection state recovery (since 4.6) can restore the session and deliver missed packets if the client returns within maxDisconnectionDuration. Check socket.recovered and do a full resync when it is false.
  • Rooms. Without recovery, a reconnected socket is a new socket in zero rooms. That is why the join lives in the connect handler above.
  • Ordering. For things like bids, send a sequence number or timestamp and let the client ignore anything older than what it already has.

Scaling Socket.IO with Redis

One Node process holds its connections in memory. Put two behind a load balancer and io.to(room).emit() on server A never reaches users connected to server B. The fix is an adapter that relays broadcasts between servers.

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

const pubClient = createClient({ url: process.env.REDIS_URL });
const subClient = pubClient.duplicate();
await Promise.all([pubClient.connect(), subClient.connect()]);

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

Two gotchas from the Socket.IO docs:

  1. Sticky sessions. If HTTP long-polling is enabled (the default), all requests from one client must hit the same server. Configure sticky sessions in your load balancer, or restrict clients to transports: ["websocket"] and accept losing the polling fallback.
  2. Recovery support varies by adapter. The classic Redis adapter uses pub/sub and does not support connection state recovery. If you need both, the Redis Streams adapter does.

Also remember serverless platforms. Classic serverless functions are short-lived and do not hold sockets, so a WebSocket server belongs on a long-running host (a container, VM or a platform built for persistent connections). I cover hosting trade-offs in deploying Next.js on Vercel vs Docker.

Common real-time mistakes

  • Creating the socket inside a component. Leads to duplicate connections and duplicate events. Create it in a module.
  • Not removing listeners. Every re-render that adds socket.on without a matching off doubles your handlers.
  • Broadcasting full state on every change. Send small events (bid:new) and let clients apply them. Send full state only on join or resync.
  • Trusting client payloads. Validate every incoming event, ideally with a schema, exactly as you would an HTTP body.
  • No auth on the handshake. Authenticate in middleware, not after connection, so unauthorised sockets never join rooms.
  • Forgetting backpressure. A slow client on a bad connection buffers messages in server memory. For high-frequency streams, throttle or drop intermediate updates.

FAQ

Should I use ws or Socket.IO?

Use ws when you control both ends and want a thin, standards-only WebSocket with minimal overhead. Use Socket.IO when you want rooms, acknowledgements, automatic reconnection, fallback to HTTP long-polling and multi-server broadcasting out of the box.

Can a browser WebSocket client connect to a Socket.IO server?

No. Socket.IO adds its own protocol on top of WebSocket, so you need the socket.io-client package on the browser side. A plain WebSocket server built with ws works with the browser's native WebSocket API.

How do I scale Socket.IO to multiple Node.js servers?

Add an adapter such as the Redis adapter so events emitted on one server reach clients connected to the others, and enable sticky sessions at the load balancer if HTTP long-polling is enabled.

Does the browser WebSocket reconnect automatically?

No. The native WebSocket API closes and stays closed. You need your own reconnection logic with backoff, or a library like Socket.IO that reconnects for you.

Wrapping up

Opening a WebSocket takes five lines. Shipping real-time features that survive bad networks takes deliberate choices: one socket per app, server-side validation, rejoin on every reconnect and an adapter once you outgrow one process. Start with Socket.IO if you are unsure, type your events, and test by killing your Wi-Fi mid-session.

  • #websockets
  • #nodejs
  • #react
  • #socket-io
  • #real-time