Skip to main content

How to Add Real-Time Features with Socket.io vs ws

·PkgPulse Team
0

TL;DR

Socket.io for browser apps with fallbacks and rooms; ws for lightweight server-to-server. Socket.io (~8M weekly downloads) adds auto-reconnect, namespaces, rooms, and broadcasting on top of WebSocket with a Socket.io-compatible client. ws (~80M downloads, used by many tools) is the raw WebSocket implementation — 10KB vs Socket.io's 200KB browser client. Use ws when you control both ends; Socket.io when you need browser support with fallbacks.

Key Takeaways

  • Socket.io: ~8M downloads — rooms, auto-reconnect, binary, browser-friendly
  • ws: ~80M downloads — raw WebSocket, minimal, server-to-server standard
  • Socket.io client: ~200KB — significant bundle cost for the browser
  • ws client: ~5KB — or use native browser WebSocket API (no package)
  • For browser apps: Socket.io saves time on reconnect, rooms, event system

Architecture Choice: WebSocket vs Long Polling vs SSE

Before writing code, choose the right transport mechanism for your use case. The three main options each have different trade-offs, and picking the wrong one early leads to expensive refactors once you hit production scale.

WebSocket establishes a persistent bidirectional TCP connection over HTTP Upgrade. Once connected, either the client or server can send messages at any time with low latency. This is the right choice for interactive features: chat, collaborative editing, multiplayer games, live cursors, real-time dashboards with user input. The key characteristic is bidirectionality — both parties can initiate messages without waiting for a request.

Server-Sent Events (SSE) is a unidirectional protocol where the server streams events to the client over a regular HTTP connection. The browser has built-in support via the EventSource API. No third-party library is required on either end. SSE is the right choice for one-way push scenarios: notification feeds, live activity logs, progress updates, price tickers, deploy log streaming. SSE is simpler than WebSocket and works through most corporate firewalls and proxies that block WebSocket upgrades. It also works seamlessly with HTTP/2 multiplexing, meaning a single connection can handle multiple SSE streams without additional overhead.

Long polling is the fallback for environments where neither WebSocket nor SSE is available. The client sends an HTTP request, the server holds the connection open until there is something to send, then the client immediately sends another request. It is less efficient than WebSocket but works everywhere. Socket.io falls back to long polling automatically when WebSocket fails, which is one of its main selling points for enterprise deployments where network policies are unpredictable.

For most new applications in 2026, choose WebSocket. Browser support is universal, and the main reason to use SSE instead is when you genuinely only need server-to-client streaming and want to avoid the overhead of a WebSocket upgrade handshake.

Choosing between Socket.io and raw ws comes down to what you're building. Socket.io gives you rooms, namespaces, auto-reconnect, binary events, and a well-tested browser client for roughly 200KB of bundle cost. The ws package gives you raw WebSocket with no abstractions, and the browser's native WebSocket API adds zero bundle cost. For browser applications where you need rooms and auto-reconnect, Socket.io's abstractions save significant implementation work — the equivalent features in raw WebSocket would take several hundred lines of careful code. For server-to-server communication or lightweight browser applications where bundle size is constrained (mobile-first progressive web apps, embedded widgets), use ws with the native browser WebSocket.


Socket.io Setup

Install Socket.io on the server:

npm install socket.io

Basic Express + Socket.io server:

// src/server.ts
import { createServer } from 'http';
import { Server } from 'socket.io';
import express from 'express';

const app = express();
const httpServer = createServer(app);

const io = new Server(httpServer, {
  cors: {
    origin: process.env.CLIENT_URL || 'http://localhost:3000',
    methods: ['GET', 'POST'],
  },
  // Transport fallback: WebSocket first, then long-polling
  transports: ['websocket', 'polling'],
});

// Authentication middleware — runs before every connection
io.use(async (socket, next) => {
  const token = socket.handshake.auth.token;
  try {
    const user = await verifyToken(token);
    socket.data.user = user;  // Attach user to socket for use in handlers
    next();
  } catch {
    next(new Error('Authentication failed'));
  }
});

io.on('connection', (socket) => {
  const user = socket.data.user;
  console.log(`${user.name} connected [${socket.id}]`);

  // Join the user's personal room (useful for targeted notifications)
  socket.join(`user:${user.id}`);

  socket.on('disconnect', (reason) => {
    console.log(`${user.name} disconnected: ${reason}`);
  });
});

httpServer.listen(3001);

The middleware pattern is important: you want to authenticate before accepting the connection, not after. Calling next(new Error(...)) rejects the connection entirely. The socket.data object is typed and persists for the lifetime of the connection, so attaching user data here makes it available in every event handler without an extra database lookup.

Install the Socket.io client:

npm install socket.io-client

Client connection and event listener code:

// React hook for Socket.io connection
import { useEffect, useRef, useState } from 'react';
import { io, Socket } from 'socket.io-client';

export function useSocket(token: string) {
  const socketRef = useRef<Socket | null>(null);
  const [connected, setConnected] = useState(false);

  useEffect(() => {
    const socket = io(process.env.NEXT_PUBLIC_WS_URL!, {
      auth: { token },
      transports: ['websocket', 'polling'],
      // Auto-reconnect configuration (defaults are reasonable)
      reconnectionAttempts: 5,
      reconnectionDelay: 1000,
    });

    socket.on('connect', () => setConnected(true));
    socket.on('disconnect', () => setConnected(false));
    socket.on('connect_error', (err) => {
      console.error('Socket connection error:', err.message);
    });

    socketRef.current = socket;
    return () => {
      socket.disconnect();
    };
  }, [token]);

  return { socket: socketRef.current, connected };
}

The useRef stores the socket instance so it does not cause re-renders on every event. The cleanup function in the useEffect return is critical — calling socket.disconnect() ensures the connection is closed when the component unmounts, preventing memory leaks and ghost connections on the server.

Working with rooms — rooms are the core of Socket.io's power. A room is a server-side group that sockets can join and leave dynamically. When you emit to a room, all sockets in that room receive the message. You never manage which clients are in a room yourself; Socket.io handles the mapping:

// Server: room join and broadcast
socket.on('join-room', (roomId: string) => {
  socket.join(roomId);
  // Notify others in the room (not the joining user)
  socket.to(roomId).emit('user-joined', { userId: user.id, name: user.name });
});

socket.on('leave-room', (roomId: string) => {
  socket.leave(roomId);
  socket.to(roomId).emit('user-left', { userId: user.id });
});

// Emit to everyone in a room (including sender):
io.to('room-123').emit('announcement', { text: 'Room updated' });

// Emit to everyone in a room (excluding sender):
socket.to('room-123').emit('peer-message', { text: 'Hello room' });

// Send to a specific user by their personal room:
function notifyUser(userId: string, event: string, data: unknown) {
  io.to(`user:${userId}`).emit(event, data);
}

The pattern of giving each user a personal room (named user:{userId}) is one of the most useful patterns in Socket.io. It lets any part of your server code send a targeted message to a specific user without knowing which socket ID they currently have — and it survives reconnects transparently, because the room join happens on every connection via the io.on('connection') handler.


ws Setup (Raw WebSocket)

Install ws:

npm install ws
npm install -D @types/ws  # TypeScript types

Basic WebSocket server with ws:

// ws — minimal WebSocket server
import { WebSocketServer, WebSocket } from 'ws';

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

// Track all connected clients
const clients = new Map<string, WebSocket>();

wss.on('connection', (ws, request) => {
  const clientId = crypto.randomUUID();
  clients.set(clientId, ws);

  ws.on('message', (data) => {
    const message = JSON.parse(data.toString()) as {
      type: string;
      payload: unknown;
    };

    switch (message.type) {
      case 'broadcast':
        // Broadcast to all connected clients
        clients.forEach((client) => {
          if (client.readyState === WebSocket.OPEN) {
            client.send(JSON.stringify({
              type: 'broadcast',
              from: clientId,
              payload: message.payload,
            }));
          }
        });
        break;

      case 'ping':
        ws.send(JSON.stringify({ type: 'pong' }));
        break;
    }
  });

  ws.on('close', () => {
    clients.delete(clientId);
  });

  ws.on('error', (error) => {
    console.error('WebSocket error:', error);
    clients.delete(clientId);
  });

  ws.send(JSON.stringify({ type: 'connected', id: clientId }));
});

Notice the client.readyState === WebSocket.OPEN check before sending. This is mandatory with raw ws — unlike Socket.io, which queues messages and handles state internally, ws will throw if you try to write to a closing or closed connection. The clients Map here serves the same purpose as Socket.io rooms, but you implement all the management yourself: who is in the group, how to address them, and how to clean up after disconnect.

The browser's native WebSocket API requires no library:

// Browser-native WebSocket — no package needed
const ws = new WebSocket('wss://api.example.com/ws');

ws.onopen = () => {
  console.log('Connected');
  ws.send(JSON.stringify({ type: 'subscribe', channel: 'prices' }));
};

ws.onmessage = (event) => {
  const data = JSON.parse(event.data);
  if (data.type === 'price-update') {
    updateUI(data.payload);
  }
};

ws.onclose = () => {
  console.log('Disconnected — reconnecting in 3s...');
  setTimeout(() => reconnect(), 3000);
};

ws.onerror = (error) => {
  console.error('WebSocket error', error);
};

The onclose handler shows what Socket.io gives you for free: reconnect logic. With raw WebSocket you must implement exponential backoff yourself. A production reconnect loop typically uses exponential backoff (starting at 1 second, doubling up to a cap of 30 seconds) with jitter to prevent thundering-herd reconnects after a server restart.


Real-Time Chat Implementation

Here is a complete mini chat room using Socket.io. This example shows the full flow: joining a room, sending messages, showing typing indicators, and handling disconnects.

Server:

// src/server.ts — Socket.io chat room
io.on('connection', (socket) => {
  const user = socket.data.user;

  socket.on('chat:join', (roomId: string) => {
    socket.join(roomId);
    socket.to(roomId).emit('chat:user-joined', {
      userId: user.id,
      name: user.name,
      timestamp: new Date().toISOString(),
    });
  });

  socket.on('chat:message', (data: { roomId: string; text: string }) => {
    const message = {
      id: crypto.randomUUID(),
      userId: user.id,
      name: user.name,
      text: data.text,
      timestamp: new Date().toISOString(),
    };
    // Send to all room members including sender
    io.to(data.roomId).emit('chat:message', message);
    // Persist to database
    db.message.create({ data: { ...message, roomId: data.roomId } });
  });

  socket.on('chat:typing', (roomId: string) => {
    socket.to(roomId).emit('chat:user-typing', { userId: user.id, name: user.name });
  });

  socket.on('chat:stop-typing', (roomId: string) => {
    socket.to(roomId).emit('chat:user-stopped-typing', { userId: user.id });
  });

  socket.on('disconnect', () => {
    // Socket.io automatically removes the socket from all rooms on disconnect
    // Emit to rooms the user was in if you need to notify others
  });
});

Client component:

function ChatRoom({ roomId, token }: { roomId: string; token: string }) {
  const { socket, connected } = useSocket(token);
  const [messages, setMessages] = useState<Message[]>([]);
  const [typingUsers, setTypingUsers] = useState<string[]>([]);

  useEffect(() => {
    if (!socket) return;
    socket.emit('chat:join', roomId);

    socket.on('chat:message', (msg: Message) => {
      setMessages(prev => [...prev, msg]);
    });
    socket.on('chat:user-typing', ({ name }: { name: string }) => {
      setTypingUsers(prev => [...prev.filter(n => n !== name), name]);
    });
    socket.on('chat:user-stopped-typing', ({ userId }: { userId: string }) => {
      setTypingUsers(prev => prev.filter(id => id !== userId));
    });

    return () => {
      socket.off('chat:message');
      socket.off('chat:user-typing');
      socket.off('chat:user-stopped-typing');
    };
  }, [socket, roomId]);

  const sendMessage = (text: string) => {
    socket?.emit('chat:message', { roomId, text });
  };

  return (
    <div>
      <div>{connected ? 'Connected' : 'Reconnecting...'}</div>
      <div>
        {messages.map(m => (
          <div key={m.id}><strong>{m.name}:</strong> {m.text}</div>
        ))}
      </div>
      {typingUsers.length > 0 && (
        <div>{typingUsers.join(', ')} is typing...</div>
      )}
    </div>
  );
}

The room abstraction is the key advantage of Socket.io here. Without it, you would need to maintain your own room-to-socket mapping, handle join/leave tracking manually, and implement the broadcast-to-room logic yourself. Socket.io makes this a single function call. The typing indicator pattern (emit on keydown, emit stop on blur or after a debounce) is a good example of the kind of micro-interaction that WebSocket makes trivial but would require polling otherwise.


Live Dashboard and Notifications with SSE

For one-way server-to-client push, Server-Sent Events are often a better choice than WebSocket. SSE is built into browsers, requires no library, and works through all HTTP/2 proxies and CDNs. The format is plain text: each message is data: ...\n\n — two newlines signal the end of a message. The browser's EventSource handles reconnection automatically, including sending the last event ID so the server can resume from where it left off.

// Express SSE endpoint
app.get('/api/events', (req, res) => {
  // Set SSE headers
  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Cache-Control', 'no-cache');
  res.setHeader('Connection', 'keep-alive');

  const userId = getUserFromRequest(req);

  // Send an initial connection confirmation
  res.write(`data: ${JSON.stringify({ type: 'connected' })}\n\n`);

  // Register this client for notifications
  clients.set(userId, res);

  // Clean up when client disconnects
  req.on('close', () => {
    clients.delete(userId);
  });
});

// Send a notification to a specific user
function sendNotification(userId: string, notification: Notification) {
  const client = clients.get(userId);
  if (client) {
    client.write(`data: ${JSON.stringify(notification)}\n\n`);
  }
}
// Browser: EventSource for SSE
const eventSource = new EventSource('/api/events');

eventSource.onmessage = (event) => {
  const data = JSON.parse(event.data);
  if (data.type === 'notification') {
    showNotification(data);
  }
};

eventSource.onerror = () => {
  // Browser automatically reconnects after an SSE error
  console.log('SSE connection lost, reconnecting...');
};

One thing SSE does not support is sending data from client to server over the same connection. That is fine for notification feeds, but if you also need the client to acknowledge receipt or interact with the stream, you need WebSocket or a separate POST request alongside the SSE stream.

SSE vs WebSocket decision:

Feature neededUse
Server pushes updates to browserSSE
Browser sends data to server in real timeWebSocket
Bidirectional (chat, collaboration)WebSocket
Notification feeds, activity streamsSSE
Works through all proxiesSSE
Binary data (audio, video frames)WebSocket
Next.js App Router streaming responsesSSE

Production Considerations

Socket.io with Redis for multiple servers. When you run more than one server instance (horizontal scaling), clients connected to different servers cannot communicate via in-memory rooms. A client on server A joins room chat-123, but a message emitted on server B to chat-123 only reaches sockets connected to server B. The Redis adapter solves this by routing room events through a pub/sub channel that all server instances subscribe to:

npm install @socket.io/redis-adapter redis
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()]);
io.adapter(createAdapter(pubClient, subClient));

This is a one-line change to your server setup and requires no changes to your event handlers. The adapter intercepts io.to('room').emit() calls and publishes them to Redis, where all server instances pick them up and forward to their local sockets.

Connection state management on reconnect. When a client reconnects after a network drop, it gets a new socket ID. Your server needs to handle this gracefully. The best approach is to re-run all setup logic on every connection event rather than treating reconnects as a special case:

io.on('connection', (socket) => {
  const user = socket.data.user;
  // Re-join personal room on every connection (handles reconnects automatically)
  socket.join(`user:${user.id}`);
});

Because io.on('connection') fires for both initial connections and reconnects, the user's personal room is always recreated without any special reconnect handling code. This is the pattern Socket.io was designed around.

Heartbeat and ping-pong. Socket.io has a built-in heartbeat mechanism (pingInterval and pingTimeout). The default settings (25 seconds interval, 20 seconds timeout) work for most applications. For mobile clients with unreliable connections, reduce these values to detect dead connections faster:

const io = new Server(httpServer, {
  pingInterval: 10000,  // 10 seconds
  pingTimeout: 5000,    // 5 seconds timeout
});

With the raw ws package, heartbeats are your responsibility. The WebSocket protocol has a built-in ping/pong frame, and ws exposes it via ws.ping() / the pong event. Run a heartbeat interval on the server, mark sockets as alive when a pong arrives, and terminate sockets that miss two consecutive heartbeats.

Rate limiting WebSocket connections is important for production. Without it, a single client can flood the server with events. Implement a simple token bucket or fixed-window counter per socket in the connection middleware, and close the socket with a 4429 custom code when limits are exceeded.


Package Health

PackageWeekly DownloadsSizeNotes
socket.io~8M200KB (browser client)Full-featured, includes client library
socket.io-client~8M200KBBrowser/Node client for socket.io server
ws~80M10KBRaw WebSocket, used internally by many tools
native WebSocket0KBBrowser built-in, no package needed

Both socket.io and ws are actively maintained. ws is downloaded 10x more than Socket.io primarily because it is used as a dependency by many developer tools (webpack, jest, VSCode language servers) — not because it is the more popular choice for application development. For building real-time features in applications, Socket.io is the dominant choice. The ws download count is a dependency count, not a direct usage count.


When to Choose

Picking the right library is straightforward once you map your requirements to the table below:

ScenarioPick
Browser chat appSocket.io
Live notifications in web appSocket.io or SSE
Need rooms and namespacesSocket.io
Fallback for browsers without WebSocketSocket.io
Server-to-server streamingws
Minimal browser bundlews or native WebSocket
Internal microservice messagingws or use MQTT/AMQP
Next.js App Router push updatesServer-Sent Events (SSE)
Collaborative editing (operational transforms/CRDTs)Socket.io or yjs
Multi-server deploymentSocket.io + Redis adapter

Socket.io is the right default for any browser-facing real-time feature. The 200KB bundle cost is real but acceptable for most applications, and the features you get in return — rooms, auto-reconnect, fallback transports, binary events — would take weeks to implement correctly yourself.

ws is the right choice when you control both ends of the connection, you are building server-to-server pipelines, or you are working in a context where bundle size is a hard constraint. The lack of abstractions means more code, but also more predictable performance and zero magic.

SSE is the right choice when data only flows from server to client. It is simpler to implement, easier to debug (it is just HTTP), and more compatible with HTTP/2 infrastructure. Use it for notification feeds, activity logs, and progress updates where the client does not need to send messages back.


Further Reading

The 2026 JavaScript Stack Cheatsheet

One PDF: the best package for every category (ORMs, bundlers, auth, testing, state management). Used by 500+ devs. Free, updated monthly.