How to Add Real-Time Features with Socket.io vs ws
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
WebSocketAPI (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 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:
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
| 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
See the live comparison
View socketio vs. ws on PkgPulse →