WebSockets for Real-Time Features: Architecture and Scaling
A practical guide to WebSockets in production — connection management, broadcasting, authentication, horizontal scaling with Redis pub/sub, and when to use SSE instead.

James Ross Jr.
Strategic Systems Architect & Enterprise Software Developer
Real-time features — live notifications, collaborative editing, live dashboards, chat — require persistent connections between client and server. WebSockets provide this persistent, bidirectional connection. The implementation is not particularly complicated for a single server. The challenge is what happens when you need more than one server.
This guide covers WebSocket implementation and the architectural patterns that make real-time features work at scale.
When WebSockets vs SSE vs Polling
Before choosing WebSockets, confirm they are the right tool:
Server-Sent Events (SSE) is simpler when communication is one-directional (server to client only). Live dashboard updates, news feeds, notification streams — SSE handles these with less complexity. SSE is HTTP, works through proxies and CDNs, and has built-in reconnection.
Long polling is the fallback when WebSockets are not available. The client makes an HTTP request, the server holds it open until there is data, then responds and the client immediately makes another request. It works everywhere but is less efficient than WebSockets for high-frequency updates.
WebSockets are the right choice when: bidirectional communication is needed (both client sends and server sends), you need low latency for high-frequency updates, or you need to push to specific clients.
WebSocket Server Setup
With Hono and the built-in WebSocket helper:
// server/api/ws.ts
import { Hono } from 'hono'
import { upgradeWebSocket } from 'hono/ws'
type ConnectionMap = Map<string, Set<WebSocket>>
const rooms: ConnectionMap = new Map()
export const wsRouter = new Hono()
wsRouter.get(
'/ws/rooms/:roomId',
requireAuthWs,
upgradeWebSocket((c) => {
const roomId = c.req.param('roomId')
const userId = c.get('userId')
return {
onOpen(event, ws) {
// Add connection to room
if (!rooms.has(roomId)) rooms.set(roomId, new Set())
rooms.get(roomId)!.add(ws.raw)
// Notify others in the room
broadcastToRoom(roomId, {
type: 'user.joined',
userId,
timestamp: new Date().toISOString(),
}, ws.raw)
},
onMessage(event, ws) {
const message = JSON.parse(event.data as string)
handleMessage(message, roomId, userId, ws)
},
onClose(event, ws) {
// Remove connection from room
rooms.get(roomId)?.delete(ws.raw)
if (rooms.get(roomId)?.size === 0) rooms.delete(roomId)
broadcastToRoom(roomId, {
type: 'user.left',
userId,
timestamp: new Date().toISOString(),
})
},
onError(event, ws) {
console.error('WebSocket error:', event)
rooms.get(roomId)?.delete(ws.raw)
},
}
})
)
function broadcastToRoom(
roomId: string,
message: unknown,
exclude?: WebSocket
) {
const connections = rooms.get(roomId)
if (!connections) return
const payload = JSON.stringify(message)
for (const ws of connections) {
if (ws !== exclude && ws.readyState === WebSocket.OPEN) {
ws.send(payload)
}
}
}
Authentication Over WebSockets
WebSocket connections do not send cookies or auth headers on upgrade (browsers do not allow custom headers on WebSocket upgrade requests). Authentication approaches:
Token in query string (during connection):
// Client
const ws = new WebSocket(`wss://api.yourdomain.com/ws?token=${accessToken}`)
// Server middleware
async function requireAuthWs(c: Context, next: Next) {
const token = c.req.query('token')
if (!token) return c.text('Unauthorized', 401)
try {
const payload = await verifyAccessToken(token)
c.set('userId', payload.sub)
await next()
} catch {
return c.text('Unauthorized', 401)
}
}
First message authentication:
onMessage(event, ws) {
const message = JSON.parse(event.data as string)
if (!authenticated) {
if (message.type !== 'auth') {
ws.close(4001, 'Authentication required')
return
}
const user = await verifyToken(message.token)
if (!user) {
ws.close(4001, 'Invalid token')
return
}
authenticated = true
userId = user.id
ws.send(JSON.stringify({ type: 'auth.success' }))
return
}
// Handle normal messages
}
I prefer the first-message approach for better flexibility and because it does not expose tokens in server logs.
The Scaling Problem
A single WebSocket server works fine for thousands of connections. Two servers create a problem: a message intended for a client connected to server A may be delivered to server B, where that client does not exist.
The solution is a pub/sub system (typically Redis) that all server instances subscribe to. When any server needs to send a message to a room, it publishes to Redis. All servers receive the message and deliver it to their connected clients in that room.
Redis Pub/Sub for Horizontal Scaling
import Redis from 'ioredis'
const publisher = new Redis(process.env.REDIS_URL!)
const subscriber = new Redis(process.env.REDIS_URL!)
// Subscribe to room channels on startup
subscriber.on('message', (channel, message) => {
const roomId = channel.replace('room:', '')
const parsed = JSON.parse(message)
// Deliver to local connections in this room
const connections = rooms.get(roomId)
if (!connections) return
const payload = JSON.stringify(parsed)
for (const ws of connections) {
if (ws.readyState === WebSocket.OPEN) {
ws.send(payload)
}
}
})
// Subscribe when a user joins a room
async function subscribeToRoom(roomId: string) {
await subscriber.subscribe(`room:${roomId}`)
}
// Publish when broadcasting (all servers receive this)
async function publishToRoom(roomId: string, message: unknown) {
await publisher.publish(`room:${roomId}`, JSON.stringify(message))
}
Now broadcasting to a room works correctly regardless of which server handles the connection:
// In your message handler
async function handleMessage(message: unknown, roomId: string, userId: string) {
// Publish through Redis so all server instances receive it
await publishToRoom(roomId, {
type: 'message',
from: userId,
content: message.content,
timestamp: new Date().toISOString(),
})
}
Connection Management
Track connection metadata for presence features:
interface Connection {
ws: WebSocket
userId: string
roomId: string
connectedAt: Date
lastPing: Date
}
const connections = new Map<string, Connection>()
// Heartbeat to detect dead connections
setInterval(() => {
const now = Date.now()
for (const [id, conn] of connections) {
if (now - conn.lastPing.getTime() > 60000) {
// Connection has not responded to ping in 60 seconds
conn.ws.terminate()
connections.delete(id)
} else if (conn.ws.readyState === WebSocket.OPEN) {
conn.ws.ping()
}
}
}, 30000)
// Update lastPing on pong
ws.on('pong', () => {
const conn = connections.get(connectionId)
if (conn) conn.lastPing = new Date()
})
Server-Sent Events: The Simpler Alternative
For one-directional server-to-client updates, SSE is simpler and works better through proxies:
// Server-Sent Events endpoint
app.get('/api/events', requireAuth, (c) => {
const userId = c.get('userId')
const stream = new ReadableStream({
start(controller) {
// Register this stream for the user
const send = (data: unknown) => {
controller.enqueue(`data: ${JSON.stringify(data)}\n\n`)
}
userStreams.set(userId, send)
// Send initial connection event
send({ type: 'connected', timestamp: new Date().toISOString() })
// Clean up on disconnect
return () => {
userStreams.delete(userId)
}
},
})
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
},
})
})
// Sending to a specific user
function notifyUser(userId: string, event: unknown) {
const send = userStreams.get(userId)
send?.(event)
}
In the Vue/Nuxt frontend:
// composables/useSSE.ts
export function useSSE(url: string) {
const lastEvent = ref<unknown>(null)
let eventSource: EventSource | null = null
onMounted(() => {
eventSource = new EventSource(url, { withCredentials: true })
eventSource.onmessage = (event) => {
lastEvent.value = JSON.parse(event.data)
}
eventSource.onerror = () => {
// EventSource reconnects automatically
}
})
onUnmounted(() => {
eventSource?.close()
})
return { lastEvent: readonly(lastEvent) }
}
Client-Side WebSocket With Auto-Reconnect
// composables/useWebSocket.ts
export function useWebSocket(url: string) {
const status = ref<'connecting' | 'connected' | 'disconnected'>('disconnected')
const lastMessage = ref<unknown>(null)
let ws: WebSocket | null = null
let reconnectTimeout: ReturnType<typeof setTimeout>
function connect() {
status.value = 'connecting'
ws = new WebSocket(url)
ws.onopen = () => { status.value = 'connected' }
ws.onmessage = (event) => {
lastMessage.value = JSON.parse(event.data)
}
ws.onclose = (event) => {
status.value = 'disconnected'
// Auto-reconnect unless deliberately closed
if (!event.wasClean) {
reconnectTimeout = setTimeout(connect, 3000)
}
}
}
function send(data: unknown) {
if (ws?.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(data))
}
}
onMounted(connect)
onUnmounted(() => {
clearTimeout(reconnectTimeout)
ws?.close(1000, 'Component unmounted')
})
return { status: readonly(status), lastMessage: readonly(lastMessage), send }
}
Real-time features require careful architecture from the start. The single-server implementation is straightforward; the scaling architecture needs to be designed before you need it.
Adding real-time features to your application or designing a WebSocket architecture that needs to scale? I have built these systems and can help you get the architecture right. Book a call: calendly.com/jamesrossjr.