April 18, 2026·8 min·By

WebSockets in Production with Node.js: Real-Time at Scale

WebSocketsNode.jsreal-timeSocket.ioRedis pub/sub

Building a simple WebSocket server is easy. Building one that handles 10,000 concurrent connections across multiple servers is not. Here's what you need to know.

Raw WebSocket Server

import { WebSocketServer } from 'ws'

const wss = new WebSocketServer({ port: 8080 })

const clients = new Map()  // connectionId -> WebSocket

wss.on('connection', (ws, req) => {
    const connectionId = crypto.randomUUID()
    const userId = getUserFromToken(req)  // Extract from auth header

    clients.set(connectionId, { ws, userId })

    ws.on('message', (data) => {
        try {
            const message = JSON.parse(data.toString())
            handleMessage(connectionId, userId, message)
        } catch (e) {
            ws.send(JSON.stringify({ error: 'Invalid JSON' }))
        }
    })

    ws.on('close', () => {
        clients.delete(connectionId)
    })

    ws.on('error', (err) => {
        console.error(`WebSocket error for ${connectionId}:`, err.message)
        clients.delete(connectionId)
    })

    // Send acknowledgment
    ws.send(JSON.stringify({ type: 'connected', connectionId }))
})

Authentication

import { verify } from 'jsonwebtoken'

function getUserFromToken(req: IncomingMessage): string | null {
    // Token can be in Authorization header or query param
    const token = req.headers['authorization']?.replace('Bearer ', '')
        || new URL(req.url!, 'http://x').searchParams.get('token')

    if (!token) return null

    try {
        const decoded = verify(token, process.env.JWT_SECRET!) as { userId: string }
        return decoded.userId
    } catch {
        return null
    }
}

wss.on('connection', (ws, req) => {
    const userId = getUserFromToken(req)
    if (!userId) {
        ws.close(1008, 'Unauthorized')
        return
    }
    // Continue with authenticated connection
})

Heartbeat / Ping-Pong

Browser and OS don't always close WebSocket connections cleanly. Detect dead connections with ping-pong:

const HEARTBEAT_INTERVAL = 30_000

function setupHeartbeat(ws: WebSocket, connectionId: string) {
    let isAlive = true

    ws.on('pong', () => { isAlive = true })

    const interval = setInterval(() => {
        if (!isAlive) {
            ws.terminate()
            clients.delete(connectionId)
            clearInterval(interval)
            return
        }
        isAlive = false
        ws.ping()
    }, HEARTBEAT_INTERVAL)

    ws.on('close', () => clearInterval(interval))
}

Horizontal Scaling with Redis Pub/Sub

A single WebSocket server can't reach connections on other servers. Use Redis to broadcast across instances:

import { createClient } from 'redis'

const publisher = createClient({ url: process.env.REDIS_URL })
const subscriber = createClient({ url: process.env.REDIS_URL })

await publisher.connect()
await subscriber.connect()

// Subscribe to messages from other servers
await subscriber.subscribe('broadcast', (message) => {
    const { channel, data, excludeConnectionId } = JSON.parse(message)

    for (const [connId, { ws, userId }] of clients) {
        if (connId === excludeConnectionId) continue
        if (channel && !userSubscribedTo(userId, channel)) continue
        if (ws.readyState === WebSocket.OPEN) {
            ws.send(JSON.stringify(data))
        }
    }
})

// Broadcast to all connections across all servers
async function broadcastToAll(data: object, excludeConnectionId?: string) {
    await publisher.publish('broadcast', JSON.stringify({
        channel: null,
        data,
        excludeConnectionId
    }))
}

// Broadcast to specific channel subscribers
async function broadcastToChannel(channel: string, data: object) {
    await publisher.publish('broadcast', JSON.stringify({ channel, data }))
}

Client-Side Reconnection

class ReconnectingWebSocket {
    private ws: WebSocket | null = null
    private reconnectDelay = 1000
    private maxDelay = 30000

    constructor(private url: string, private onMessage: (data: any) => void) {
        this.connect()
    }

    private connect() {
        this.ws = new WebSocket(this.url)

        this.ws.onopen = () => {
            console.log('Connected')
            this.reconnectDelay = 1000  // Reset on success
        }

        this.ws.onmessage = (event) => {
            this.onMessage(JSON.parse(event.data))
        }

        this.ws.onclose = () => {
            console.log(`Disconnected. Reconnecting in ${this.reconnectDelay}ms`)
            setTimeout(() => this.connect(), this.reconnectDelay)
            this.reconnectDelay = Math.min(this.reconnectDelay * 2, this.maxDelay)
        }

        this.ws.onerror = (err) => {
            console.error('WebSocket error:', err)
            this.ws?.close()
        }
    }

    send(data: object) {
        if (this.ws?.readyState === WebSocket.OPEN) {
            this.ws.send(JSON.stringify(data))
        }
    }
}

Exponential backoff prevents thundering herd when your server restarts.

Connection Limits

const MAX_CONNECTIONS = 10_000

wss.on('connection', (ws, req) => {
    if (clients.size >= MAX_CONNECTIONS) {
        ws.close(1013, 'Server overloaded')
        return
    }
    // ...
})

A single Node.js process can handle ~10,000-50,000 WebSocket connections depending on message volume. Beyond that, run multiple processes behind a load balancer with sticky sessions or the Redis pub/sub pattern above.

K
Founder & Technical Lead, Innovibe

Building software for 15+ years. Passionate about AI, system design, and shipping things that work.

Frequently asked questions

How do I handle database migrations safely in production?+

Always run migrations before deploying new code (never after). Use additive-only migrations — add columns with defaults, never rename or drop in the same release. Blue-green deployments make this much safer.

When should I add a Redis cache vs just querying Postgres?+

Add Redis when the same query runs more than ~10 times per second, when query time exceeds 10ms and latency matters, or when you need pub/sub. Don't add it preemptively — optimise the query first.

Does Innovibe build this kind of thing for clients?+

Yes — this is exactly what we do day-to-day for clients across BC and Canada. If you'd rather have us build and maintain it than implement it yourself, reach out.

How do I decide whether to build this in-house or hire an agency?+

Build in-house if your team has the skills and bandwidth and this is core to your product. Hire out if it's infrastructure, if speed matters, or if the expertise gap would take months to close. We're biased, obviously — but we'll tell you honestly when in-house makes more sense.

What tech stack does Innovibe use for projects like this?+

Next.js + TypeScript on the frontend, Node.js or Go on the backend, Postgres for the primary data store, and GCP (Cloud Run, BigQuery, Pub/Sub) for infrastructure. We pick tools that are boring in the best way — proven, well-documented, and easy to hire for.

Building something with AI?

We scope and ship AI features quickly. Let's talk.

Start a Conversation