WebSocket and Real-Time Tools: From Socket.IO to PartyKit
WebSocket and Real-Time Tools: From Socket.IO to PartyKit
Real-time features -- live chat, collaborative editing, dashboards that update without refreshing, multiplayer anything -- all share the same fundamental requirement: the server needs to push data to the client without the client asking for it. HTTP was not designed for this. WebSockets, Server-Sent Events, and newer protocols like WebTransport fill the gap.
The tooling landscape ranges from raw WebSocket libraries to fully managed services that handle presence, state synchronization, and scaling. Choosing the right tool depends on what kind of real-time you need, how many concurrent connections you will handle, and how much infrastructure you want to manage.
Protocols: WS, SSE, and WebTransport
Before picking a tool, understand the three main protocols for real-time communication.
WebSocket
WebSocket provides full-duplex communication over a single TCP connection. Both client and server can send messages at any time. This is the protocol most people mean when they say "real-time."
Client Server
|--- HTTP Upgrade ---->|
|<--- 101 Switching ---|
| |
|<--- message ---------|
|--- message --------->|
|<--- message ---------|
|--- message --------->|
| |
|--- close ----------->|
|<--- close -----------|
Pros: Bidirectional, low latency, widely supported. Cons: Stateful connections complicate load balancing, no built-in reconnection, firewalls and proxies can interfere.
Server-Sent Events (SSE)
SSE is a unidirectional protocol -- the server pushes events to the client over a long-lived HTTP connection. The client cannot send messages back over the same connection (use regular HTTP requests for that).
Client Server
|--- GET /events ----->|
|<--- event: update ---|
|<--- event: update ---|
|<--- event: alert ---|
| |
|--- POST /action ---->| (separate HTTP request)
|<--- event: update ---|
Pros: Uses standard HTTP (works through proxies and CDNs), automatic reconnection built into the browser API, simpler than WebSockets. Cons: Server-to-client only, limited to text data (no binary), max 6 connections per domain in HTTP/1.1 (not an issue with HTTP/2).
WebTransport
WebTransport is the newest option, built on HTTP/3 and QUIC. It provides bidirectional communication with support for both reliable (ordered) and unreliable (unordered, like UDP) data streams.
// WebTransport is still emerging -- browser support is growing
const transport = new WebTransport('https://example.com/wt');
await transport.ready;
const writer = transport.datagrams.writable.getWriter();
await writer.write(new Uint8Array([1, 2, 3]));
const reader = transport.datagrams.readable.getReader();
const { value } = await reader.read();
Pros: Multiplexed streams (no head-of-line blocking), unreliable datagrams for game-like use cases, modern and fast. Cons: Requires HTTP/3 infrastructure, limited browser support, few server libraries. Not production-ready for most teams as of early 2026.
Which Protocol to Use
| Use Case | Protocol | Why |
|---|---|---|
| Live dashboard, notifications | SSE | Server-to-client only, simple |
| Chat, collaboration | WebSocket | Bidirectional, low latency |
| Multiplayer games | WebSocket or WebTransport | Need bidirectional + low latency |
| Live feeds (news, prices) | SSE | One-way, resilient to reconnection |
| File upload progress | SSE | Server-to-client progress events |
Low-Level Libraries
ws (Node.js)
ws is the most popular WebSocket library for Node.js. It is fast, spec-compliant, and low-level -- it gives you a WebSocket connection and nothing else.
npm install ws
// server.ts
import { WebSocketServer, WebSocket } from 'ws';
const wss = new WebSocketServer({ port: 8080 });
const clients = new Set<WebSocket>();
wss.on('connection', (ws) => {
clients.add(ws);
console.log(`Client connected. Total: ${clients.size}`);
ws.on('message', (data) => {
const message = JSON.parse(data.toString());
// Broadcast to all other clients
for (const client of clients) {
if (client !== ws && client.readyState === WebSocket.OPEN) {
client.send(JSON.stringify(message));
}
}
});
ws.on('close', () => {
clients.delete(ws);
});
// Heartbeat to detect dead connections
ws.on('pong', () => {
(ws as any).isAlive = true;
});
});
// Ping all clients every 30 seconds, terminate dead ones
setInterval(() => {
for (const ws of clients) {
if ((ws as any).isAlive === false) {
clients.delete(ws);
return ws.terminate();
}
(ws as any).isAlive = false;
ws.ping();
}
}, 30_000);
// client.ts (browser)
const ws = new WebSocket('ws://localhost:8080');
ws.onopen = () => {
ws.send(JSON.stringify({ type: 'join', room: 'general' }));
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
console.log('Received:', data);
};
ws.onclose = () => {
// Manual reconnection
setTimeout(() => {
// reconnect logic
}, 1000);
};
ws is the right choice when you need low-level control and your use case is simple enough that you do not need rooms, namespaces, or automatic reconnection. For anything more complex, you will end up reimplementing Socket.IO's feature set.
Socket.IO
Socket.IO adds a layer of features on top of WebSockets: automatic reconnection, room-based messaging, namespaces, binary support, and fallback transports (long polling when WebSocket is unavailable).
npm install socket.io # Server
npm install socket.io-client # Client
// server.ts
import { Server } from 'socket.io';
const io = new Server(3001, {
cors: { origin: 'http://localhost:3000' },
// Adapter for multi-server scaling
// adapter: createAdapter(redisClient),
});
io.on('connection', (socket) => {
console.log(`Connected: ${socket.id}`);
// Room management
socket.on('join-room', (roomId: string) => {
socket.join(roomId);
socket.to(roomId).emit('user-joined', {
userId: socket.id,
timestamp: Date.now(),
});
});
// Typed events
socket.on('chat-message', (data: { room: string; text: string }) => {
io.to(data.room).emit('chat-message', {
userId: socket.id,
text: data.text,
timestamp: Date.now(),
});
});
// Acknowledgments (request/response over WebSocket)
socket.on('get-room-users', (roomId: string, callback) => {
const room = io.sockets.adapter.rooms.get(roomId);
callback(room ? Array.from(room) : []);
});
socket.on('disconnect', (reason) => {
console.log(`Disconnected: ${socket.id}, reason: ${reason}`);
});
});
// client.ts
import { io } from 'socket.io-client';
const socket = io('http://localhost:3001', {
reconnectionAttempts: 5,
reconnectionDelay: 1000,
});
socket.on('connect', () => {
socket.emit('join-room', 'general');
});
socket.on('chat-message', (data) => {
console.log(`${data.userId}: ${data.text}`);
});
// Request/response pattern
socket.emit('get-room-users', 'general', (users: string[]) => {
console.log('Users in room:', users);
});
socket.on('connect_error', (error) => {
console.log('Connection error:', error.message);
});
When to use Socket.IO: When you need rooms, reconnection, and broadcasting out of the box and are okay with the Socket.IO protocol (not raw WebSocket -- Socket.IO uses its own framing on top of WS, so you cannot connect a plain WebSocket client to a Socket.IO server).
Managed Real-Time Services
If you do not want to run WebSocket servers yourself, managed services handle the infrastructure, scaling, and edge distribution.
Pusher
Pusher is the original managed real-time service. It provides channels (pub/sub) with authentication, presence, and client events.
// Server-side: trigger events
import Pusher from 'pusher';
const pusher = new Pusher({
appId: 'APP_ID',
key: 'APP_KEY',
secret: 'APP_SECRET',
cluster: 'us2',
});
// Trigger an event on a channel
await pusher.trigger('chat-general', 'message', {
userId: 'user_123',
text: 'Hello everyone',
timestamp: Date.now(),
});
// Presence channels -- track who's online
// (requires auth endpoint)
// Client-side
import Pusher from 'pusher-js';
const pusher = new Pusher('APP_KEY', { cluster: 'us2' });
const channel = pusher.subscribe('chat-general');
channel.bind('message', (data: { userId: string; text: string }) => {
console.log(`${data.userId}: ${data.text}`);
});
// Presence channel -- see who's online
const presence = pusher.subscribe('presence-general');
presence.bind('pusher:subscription_succeeded', (members: any) => {
console.log('Online:', members.count);
});
Pros: Simple API, reliable, good documentation. Just works for basic pub/sub. Cons: Gets expensive at scale. Message limits and connection limits on lower tiers. The pricing model can be surprising -- you pay per message and per connection.
Ably
Ably is a more feature-rich alternative to Pusher. It provides pub/sub, presence, message history, and push notifications with stronger consistency guarantees.
import Ably from 'ably';
// Server
const ably = new Ably.Rest('API_KEY');
const channel = ably.channels.get('chat:general');
await channel.publish('message', {
userId: 'user_123',
text: 'Hello from Ably',
});
// Client (with realtime connection)
const ablyClient = new Ably.Realtime('API_KEY');
const clientChannel = ablyClient.channels.get('chat:general');
clientChannel.subscribe('message', (message) => {
console.log(message.data);
});
// Presence
clientChannel.presence.enter({ status: 'online' });
clientChannel.presence.subscribe('enter', (member) => {
console.log(`${member.clientId} came online`);
});
Ably's differentiator over Pusher is message ordering guarantees, history/persistence, and a more generous free tier. If you are building something where message delivery matters (financial data, collaborative editing), Ably's guarantees are worth the premium.
Supabase Realtime
Supabase Realtime lets you listen to database changes over WebSockets. If you are already using Supabase, this is the lowest-friction way to add real-time features.
import { createClient } from '@supabase/supabase-js';
const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY);
// Listen to database changes
const channel = supabase
.channel('messages')
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'messages',
filter: 'room_id=eq.general',
},
(payload) => {
console.log('New message:', payload.new);
}
)
.subscribe();
// Broadcast (no database -- pure pub/sub)
const broadcastChannel = supabase.channel('room:general');
broadcastChannel
.on('broadcast', { event: 'cursor' }, (payload) => {
console.log('Cursor moved:', payload);
})
.subscribe();
broadcastChannel.send({
type: 'broadcast',
event: 'cursor',
payload: { x: 100, y: 200 },
});
// Presence
const presenceChannel = supabase.channel('online-users');
presenceChannel
.on('presence', { event: 'sync' }, () => {
console.log('Online:', presenceChannel.presenceState());
})
.subscribe(async (status) => {
if (status === 'SUBSCRIBED') {
await presenceChannel.track({ user_id: 'user_123', online: true });
}
});
Pros: Integrated with Supabase database, Row Level Security applies to realtime subscriptions, presence and broadcast built in. Cons: Tied to Supabase ecosystem, connection limits on free tier (200 concurrent), Postgres changes have a slight delay compared to direct WebSocket messages.
Collaboration-Focused Tools
Liveblocks
Liveblocks is purpose-built for collaborative features -- cursors, selection, real-time document editing, comments, notifications. If you are building something like Figma or Google Docs, Liveblocks gives you the hard parts.
// liveblocks.config.ts
import { createClient } from '@liveblocks/client';
import { createRoomContext } from '@liveblocks/react';
const client = createClient({
publicApiKey: 'pk_live_xxx',
});
type Storage = {
todos: LiveList<{ text: string; checked: boolean }>;
};
type Presence = {
cursor: { x: number; y: number } | null;
selectedId: string | null;
};
export const {
RoomProvider,
useOthers,
useMyPresence,
useStorage,
useMutation,
} = createRoomContext<Presence, Storage>(client);
// App.tsx
import { RoomProvider, useOthers, useMyPresence, useMutation, useStorage } from './liveblocks.config';
import { LiveList } from '@liveblocks/client';
function TodoApp() {
const todos = useStorage((root) => root.todos);
const others = useOthers();
const [myPresence, updatePresence] = useMyPresence();
const addTodo = useMutation(({ storage }, text: string) => {
storage.get('todos').push({ text, checked: false });
}, []);
return (
<div onPointerMove={(e) => updatePresence({ cursor: { x: e.clientX, y: e.clientY } })}>
{/* Show other users' cursors */}
{others.map(({ connectionId, presence }) => (
presence.cursor && (
<Cursor key={connectionId} x={presence.cursor.x} y={presence.cursor.y} />
)
))}
<ul>
{todos?.map((todo, i) => (
<li key={i}>{todo.text}</li>
))}
</ul>
<button onClick={() => addTodo('New todo')}>Add</button>
</div>
);
}
export default function App() {
return (
<RoomProvider
id="my-room"
initialPresence={{ cursor: null, selectedId: null }}
initialStorage={{ todos: new LiveList([]) }}
>
<TodoApp />
</RoomProvider>
);
}
Liveblocks handles conflict resolution, offline support, and presence without you thinking about CRDTs or operational transforms. The trade-off is you work within their data model (LiveObject, LiveList, LiveMap).
PartyKit
PartyKit takes a different approach -- it gives you a serverless platform where each "party" (room) runs its own stateful server instance. You write the server logic, PartyKit handles deployment and scaling.
// party/chatroom.ts
import type * as Party from 'partykit/server';
export default class ChatRoom implements Party.Server {
messages: Array<{ user: string; text: string; timestamp: number }> = [];
constructor(readonly room: Party.Room) {}
onConnect(conn: Party.Connection) {
// Send message history to new connections
conn.send(JSON.stringify({ type: 'history', messages: this.messages }));
// Notify others
this.room.broadcast(
JSON.stringify({
type: 'user-joined',
id: conn.id,
}),
[conn.id] // exclude the new connection
);
}
onMessage(message: string, sender: Party.Connection) {
const data = JSON.parse(message);
if (data.type === 'chat') {
const msg = {
user: sender.id,
text: data.text,
timestamp: Date.now(),
};
this.messages.push(msg);
// Broadcast to all connections including sender
this.room.broadcast(JSON.stringify({ type: 'message', ...msg }));
}
}
onClose(conn: Party.Connection) {
this.room.broadcast(
JSON.stringify({ type: 'user-left', id: conn.id })
);
}
}
// Client
import PartySocket from 'partysocket';
const socket = new PartySocket({
host: 'my-project.username.partykit.dev',
room: 'general',
});
socket.addEventListener('message', (event) => {
const data = JSON.parse(event.data);
console.log(data);
});
socket.send(JSON.stringify({ type: 'chat', text: 'Hello!' }));
What makes PartyKit interesting: Each room is a stateful server that persists in memory between messages. You can store state directly on the class instance without a database. PartyKit hibernates idle rooms and wakes them when needed. This model is perfect for game rooms, collaborative documents, or any use case where state is scoped to a room.
Debugging Real-Time Connections
Debugging WebSocket connections is harder than debugging HTTP requests. Here are the tools that help.
wscat
npm install -g wscat
# Connect to a WebSocket server
wscat -c ws://localhost:8080
# Send a message
> {"type": "ping"}
# With headers
wscat -c ws://localhost:8080 -H "Authorization: Bearer token123"
Browser DevTools
Chrome and Firefox DevTools show WebSocket frames in the Network tab. Click on the WebSocket connection and switch to the "Messages" tab to see sent and received frames with timestamps.
Postman WebSocket Support
Postman added WebSocket support -- you can connect to a WS endpoint, send messages, and inspect responses. Useful for testing without writing client code.
Logging Middleware for Socket.IO
// Log all events for debugging
io.use((socket, next) => {
const originalEmit = socket.emit;
socket.emit = function (...args: any[]) {
console.log(`[OUT] ${socket.id}:`, args[0], args[1]);
return originalEmit.apply(socket, args);
};
socket.onAny((event, ...args) => {
console.log(`[IN] ${socket.id}:`, event, args);
});
next();
});
Scaling Challenges
Scaling WebSocket connections is fundamentally different from scaling HTTP. HTTP is stateless -- any server can handle any request. WebSocket connections are stateful -- a client is connected to a specific server, and that server holds the connection state.
The Problem with Load Balancers
Standard HTTP load balancers distribute requests round-robin. WebSocket connections need sticky sessions -- once a client connects, all its traffic must go to the same server.
# Nginx WebSocket proxy with sticky sessions
upstream websocket_servers {
ip_hash; # Sticky sessions based on client IP
server ws1.internal:8080;
server ws2.internal:8080;
}
server {
location /ws {
proxy_pass http://websocket_servers;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 86400; # 24h -- prevent proxy timeout
}
}
Multi-Server Broadcasting with Redis
When you have multiple WebSocket servers, a message sent to one server needs to reach clients connected to other servers. Redis Pub/Sub is the standard solution.
// Socket.IO with Redis adapter
import { Server } from 'socket.io';
import { createAdapter } from '@socket.io/redis-adapter';
import { createClient } from 'redis';
const pubClient = createClient({ url: 'redis://localhost:6379' });
const subClient = pubClient.duplicate();
await Promise.all([pubClient.connect(), subClient.connect()]);
const io = new Server(3001);
io.adapter(createAdapter(pubClient, subClient));
// Now io.emit() broadcasts across all servers
io.emit('announcement', 'This reaches all connected clients');
Connection Limits
A single server can handle tens of thousands of WebSocket connections, but each connection consumes memory (for the TCP socket, buffers, and application state). Plan for roughly 1-10 KB per connection, depending on what state you hold.
Bottom Line
For simple pub/sub (notifications, live feeds): Use Server-Sent Events. They are simpler than WebSockets, work through CDNs, and reconnect automatically. No library needed -- the browser EventSource API is sufficient.
For chat, real-time dashboards, basic collaboration: Socket.IO if you are self-hosting. Pusher or Ably if you want managed infrastructure. Socket.IO's room abstraction and automatic reconnection save significant development time.
For collaborative editing (Figma-like, Google Docs-like): Liveblocks. Do not try to build CRDTs from scratch. The research and engineering required to get conflict resolution right is enormous. Liveblocks has done it.
For game-like or room-based use cases: PartyKit. The stateful server-per-room model is a natural fit and the developer experience is excellent.
For database-driven real-time: Supabase Realtime if you are in the Supabase ecosystem. It is the lowest-friction option for "show new rows as they are inserted."
Avoid WebTransport for now unless you are building something that specifically needs unreliable datagrams (games, video). The ecosystem is not mature enough for general use.
The single biggest mistake teams make with real-time is building it themselves when a managed service would be cheaper. Running WebSocket servers at scale -- with reconnection, presence, message ordering, and horizontal scaling -- is genuinely hard. Unless real-time is your core product, pay for infrastructure and focus on features.