<!-- PkgPulse AI-readable guide source -->
<!-- Canonical: https://www.pkgpulse.com/guides/how-to-add-realtime-features-socketio-vs-ws -->
<!-- Raw Markdown: https://www.pkgpulse.com/guides/how-to-add-realtime-features-socketio-vs-ws/raw.md -->
<!-- Source path: content/guides/how-to-add-realtime-features-socketio-vs-ws.mdx -->

---
og_image: "/images/guides/how-to-add-realtime-features-socketio-vs-ws.webp"
title: "How to Add Real-Time Features with Socket.io vs ws 2026"
description: "Implement real-time features in Node.js using Socket.io and ws. Chat, notifications, live updates, and when to choose Socket.io's abstractions vs the raw."
date: "2026-03-08"
author: "PkgPulse Team"
tags: ["socketio", "websockets", "realtime", "nodejs", "2026"]
featured_comparison: "socketio-vs-ws"
tier: 1
---

## 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:

```bash
npm install socket.io
```

Basic Express + Socket.io server:

```typescript
// 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:

```bash
npm install socket.io-client
```

Client connection and event listener code:

```typescript
// 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:

```typescript
// 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`:

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

Basic WebSocket server with `ws`:

```typescript
// 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:

```typescript
// 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:

```typescript
// 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:

```tsx
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.

```typescript
// 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`);
  }
}
```

```typescript
// 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 needed | Use |
|----------------|-----|
| Server pushes updates to browser | SSE |
| Browser sends data to server in real time | WebSocket |
| Bidirectional (chat, collaboration) | WebSocket |
| Notification feeds, activity streams | SSE |
| Works through all proxies | SSE |
| Binary data (audio, video frames) | WebSocket |
| Next.js App Router streaming responses | SSE |

---

## 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:

```bash
npm install @socket.io/redis-adapter redis
```

```typescript
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:

```typescript
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:

```typescript
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

| Package | Weekly Downloads | Size | Notes |
|---------|-----------------|------|-------|
| socket.io | ~8M | 200KB (browser client) | Full-featured, includes client library |
| socket.io-client | ~8M | 200KB | Browser/Node client for socket.io server |
| ws | ~80M | 10KB | Raw WebSocket, used internally by many tools |
| native WebSocket | — | 0KB | Browser 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:

| Scenario | Pick |
|----------|------|
| Browser chat app | Socket.io |
| Live notifications in web app | Socket.io or SSE |
| Need rooms and namespaces | Socket.io |
| Fallback for browsers without WebSocket | Socket.io |
| Server-to-server streaming | ws |
| Minimal browser bundle | ws or native WebSocket |
| Internal microservice messaging | ws or use MQTT/AMQP |
| Next.js App Router push updates | Server-Sent Events (SSE) |
| Collaborative editing (operational transforms/CRDTs) | Socket.io or yjs |
| Multi-server deployment | Socket.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

- [Best npm packages for real-time: Socket.io, PartyKit, Yjs compared](/guides/best-npm-packages-realtime-socketio-partykit-yjs-2026)
- [socket.io package health, download trends, and changelog](/packages/socket.io)
- [ws package health, download trends, and changelog](/packages/ws)
