Build real-time features with WebSockets — chat apps, live dashboards, collaborative editing. Covers the protocol, Socket.io, scaling with Redis, and when to use SSE instead.
HTTP is request-response: the client asks, the server answers. WebSockets flip this model — both sides can send messages at any time. This is what powers real-time chat, live dashboards, collaborative editing, and multiplayer games.
HTTP Upgrade handshake:
Client → Server:
GET /chat HTTP/1.1
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Server → Client:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
--- Connection is now a persistent TCP socket ---
Client → Server: "Hello!" (any time)
Server → Client: "Hi there!" (any time)
Server → Client: "New message from Alice" (server-initiated!)
WebSocket URLs use ws:// (plain) or wss:// (TLS) instead of http://.
// Connect
const ws = new WebSocket("wss://api.example.com/ws")
// Connection opened
ws.addEventListener("open", () => {
console.log("Connected!")
ws.send(JSON.stringify({ type: "join", room: "general" }))
})
// Receive messages
ws.addEventListener("message", (event) => {
const data = JSON.parse(event.data)
console.log("Received:", data)
})
// Handle errors and disconnection
ws.addEventListener("error", (error) => console.error("WS Error:", error))
ws.addEventListener("close", (event) => {
console.log(`Disconnected: ${event.code} ${event.reason}`)
// Reconnect logic here
})
// Send a message
ws.send(JSON.stringify({ type: "message", text: "Hello, world!" }))
// WebSocket ready states
// ws.readyState === 0 → CONNECTING
// ws.readyState === 1 → OPEN
// ws.readyState === 2 → CLOSING
// ws.readyState === 3 → CLOSED
// Close gracefully
ws.close(1000, "User logged out")
const { WebSocketServer } = require("ws")
const http = require("http")
const server = http.createServer()
const wss = new WebSocketServer({ server })
// Track connected clients by room
const rooms = new Map() // roomId → Set<WebSocket>
wss.on("connection", (ws, request) => {
const userId = getUserFromRequest(request)
ws.userId = userId
ws.on("message", (rawMessage) => {
const message = JSON.parse(rawMessage.toString())
switch (message.type) {
case "join": {
const room = rooms.get(message.room) ?? new Set()
room.add(ws)
rooms.set(message.room, room)
ws.currentRoom = message.room
break
}
case "message": {
// Broadcast to everyone in the same room
const room = rooms.get(ws.currentRoom)
if (!room) return
const payload = JSON.stringify({
type: "message",
from: ws.userId,
text: message.text,
timestamp: Date.now(),
})
for (const client of room) {
if (client.readyState === WebSocket.OPEN) {
client.send(payload)
}
}
break
}
}
})
ws.on("close", () => {
const room = rooms.get(ws.currentRoom)
room?.delete(ws)
})
})
server.listen(3001, () => console.log("WS server on :3001"))
// Server (Node.js)
const { Server } = require("socket.io")
const io = new Server(httpServer, {
cors: { origin: "https://myapp.com" }
})
io.on("connection", (socket) => {
console.log("User connected:", socket.id)
// Join a room
socket.on("join-room", (roomId) => {
socket.join(roomId)
socket.to(roomId).emit("user-joined", { userId: socket.id })
})
// Send to a room
socket.on("send-message", ({ roomId, text }) => {
io.to(roomId).emit("new-message", {
from: socket.id,
text,
timestamp: Date.now(),
})
})
socket.on("disconnect", (reason) => {
console.log("Disconnected:", reason)
})
})
// Client (browser)
import { io } from "socket.io-client"
const socket = io("wss://api.example.com")
socket.emit("join-room", "general")
socket.on("new-message", (msg) => displayMessage(msg))
socket.emit("send-message", { roomId: "general", text: "Hello!" })
Socket.io adds: automatic reconnection, rooms, namespaces, fallback to long-polling, and a broadcast API.
// When you have multiple server instances, clients on different servers
// can't see each other's messages without a message broker.
const { createClient } = require("redis")
const { createAdapter } = require("@socket.io/redis-adapter")
const pubClient = createClient({ url: REDIS_URL })
const subClient = pubClient.duplicate()
await Promise.all([pubClient.connect(), subClient.connect()])
io.adapter(createAdapter(pubClient, subClient))
// Now io.to("general").emit() works across ALL server instances
// Redis pub/sub routes the message to the right server
| Feature | WebSockets | SSE |
|---|---|---|
| Direction | Bidirectional | Server → Client only |
| Protocol | Custom (ws://) | HTTP |
| Reconnect | Manual | Automatic |
| Browser support | Excellent | Excellent |
| Load balancers | Tricky (sticky sessions) | Works with standard HTTP |
| Use case | Chat, games, collaboration | Feeds, notifications, dashboards |
// SSE is simpler when you only need server → client
// app/api/feed/route.ts (Next.js)
export async function GET() {
const stream = new ReadableStream({
start(controller) {
const send = (data: object) => {
controller.enqueue(`data: ${JSON.stringify(data)}\n\n`)
}
// Subscribe to updates
const unsub = events.on("post.created", (post) => send({ type: "new-post", post }))
// Clean up when client disconnects
return () => unsub()
}
})
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
}
})
}
// Exponential backoff reconnect
function connectWithRetry(url, onMessage) {
let retries = 0
let ws
function connect() {
ws = new WebSocket(url)
ws.onopen = () => { retries = 0 }
ws.onmessage = onMessage
ws.onclose = () => {
const delay = Math.min(1000 * 2 ** retries, 30000) // max 30s
console.log(`Reconnecting in ${delay}ms...`)
setTimeout(connect, delay)
retries++
}
}
connect()
return () => ws.close() // return cleanup function
}