Skip to main content

WebSocket Programming: Real-Time bidirectional Communication

Published: March 12, 2026 Updated: May 8, 2026 Larry Qu 33 min read

Introduction

The web was built on request-response: clients ask, servers respond. This model works for most applications but fails for anything requiring real-time, bidirectional communication. WebSockets transform this paradigm, establishing persistent connections that allow servers to push data to clients instantly.

In 2026, WebSockets power everything from collaborative editing tools to live trading platforms, from multiplayer games to IoT dashboards. This comprehensive guide explores WebSocket programming in depth, covering protocols, implementations, security, scaling patterns, and practical applications across different languages and frameworks.

Understanding WebSockets

WebSockets solve a fundamental limitation of HTTP: the request-response model requires the client to initiate every exchange. For applications like live chat, real-time dashboards, collaborative editing, and financial tickers, this means either constant polling or complex workarounds like long-polling and server-sent events. WebSockets establish a persistent, bidirectional channel over a single TCP connection, allowing either party to send data at any time with minimal overhead.

When to Choose WebSockets Over Alternatives

The decision to use WebSockets depends on the nature of your real-time requirements. If your application needs low-latency, two-way communication where the server must push events as they happen (trading platforms, multiplayer games, live collaboration), WebSockets are the clear choice. If you only need server-to-client updates at regular intervals, Server-Sent Events (SSE) offer a simpler implementation over standard HTTP. For occasional data refreshes, polling at reasonable intervals may be entirely adequate and avoids the operational complexity of maintaining persistent connections. The threshold is typically when you need sub-second delivery from server to client, or when the client must send frequent updates that would overwhelm HTTP request overhead.

How WebSockets Work

Every WebSocket connection begins its life as an ordinary HTTP request. The client sends a standard GET request that carries three special headers — Upgrade: websocket, Connection: Upgrade, and a randomly generated Sec-WebSocket-Key — to signal that it wants to switch protocols. If the server agrees, it responds with 101 Switching Protocols and computes a Sec-WebSocket-Accept value from the client’s key. From that moment onward, the two parties speak the WebSocket wire protocol directly over the same TCP socket, bypassing the HTTP layer entirely.

The diagram below walks through the complete lifecycle. Notice that the handshake is the only HTTP phase; everything after it is raw frames flowing in both directions simultaneously. The connection stays open until either side sends a close frame, at which point the socket is torn down cleanly. Understanding this lifecycle matters because it explains why WebSockets are so much faster than polling — there is no request/response ceremony, no repeated headers, and no connection setup cost after the first handshake:

┌─────────────────────────────────────────────────────────────────────┐
│                    WebSocket Connection Lifecycle                    │
├─────────────────────────────────────────────────────────────────────┤
│                                                                      │
│  HTTP Handshake:                                                    │
│  ┌─────────┐                                         ┌─────────┐   │
│  │ Client  │ ───────────────────────────────────────▶ │ Server  │   │
│  │         │  GET /ws HTTP/1.1                        │         │   │
│  │         │  Host: example.com                       │         │   │
│  │         │  Upgrade: websocket                       │         │   │
│  │         │  Connection: Upgrade                       │         │   │
│  │         │  Sec-WebSocket-Key: dGhlIHNhbXBsZSBwcmltIQ │         │   │
│  └─────────┘                                         └─────────┘   │
│         ◀───────────────────────────────────────────────  │        │
│         │  HTTP/1.1 101 Switching Protocols              │        │
│         │  Upgrade: websocket                             │        │
│         │  Connection: Upgrade                            │        │
│         │  Sec-WebSocket-Accept: s3pPLMBiT2Q...          │        │
│                                                                      │
│  WebSocket Frame Exchange:                                          │
│  ┌─────────┐                                         ┌─────────┐   │
│  │ Client  │ ◀─── Server pushes data ────────────── │ Server  │   │
│  │         │ ───── Client sends data ─────────────▶ │         │   │
│  │         │ ◀─── Server pushes data ────────────── │         │   │
│  │         │ ───── Client sends data ─────────────▶ │         │   │
│  │         │ ...                                     │         │   │
│  └─────────┘                                         └─────────┘   │
│                                                                      │
│  Close:                                                              │
│  ┌─────────┐                                         ┌─────────┐   │
│  │ Client  │ ────── Close frame (1000) ────────────▶ │ Server  │   │
│  │         │ ◀───── Close frame (1000) ──────────── │         │   │
│  └─────────┘                                         └─────────┘   │
│                                                                      │
└─────────────────────────────────────────────────────────────────────┘

WebSocket Frames

WebSocket communication occurs through frames. Every message sent over the wire is wrapped in a frame that carries control metadata in its header bytes: a FIN bit marking the final fragment, a three-bit opcode identifying the frame type, and a variable-length payload length. The opcode is what tells the receiver how to interpret the payload — whether it is UTF-8 text, raw binary, or a control frame such as PING, PONG, or CLOSE. Understanding these fields is essential for debugging, building protocol analyzers, and implementing custom clients or servers from scratch.

The Python class below models a frame and, more importantly, shows how to serialize it into the exact byte layout defined in RFC 6455. Two details are easy to get wrong. First, the length encoding is conditional: payloads under 126 bytes fit in a single length byte, payloads up to 65535 use a 16-bit length, and anything larger requires a 64-bit length. Second, client-to-server frames must be masked with a random 4-byte key — the masked flag and the XOR-based _mask_payload method implement that requirement, since the protocol forbids unmasked messages from clients while servers never mask at all. Both rules are enforced by the byte assembly logic in to_bytes:

from enum import IntEnum
from dataclasses import dataclass

class Opcode(IntEnum):
    CONTINUATION = 0x0
    TEXT = 0x1
    BINARY = 0x2
    CLOSE = 0x8
    PING = 0x9
    PONG = 0xA

class WebSocketFrame:
    def __init__(
        self,
        opcode: Opcode,
        payload: bytes,
        fin: bool = True,
        rsv1: bool = False,
        rsv2: bool = False,
        rsv3: bool = False,
        masked: bool = False,
        mask_key: bytes = None
    ):
        self.opcode = opcode
        self.payload = payload
        self.fin = fin
        self.rsv1 = rsv1
        self.rsv2 = rsv2
        self.rsv3 = rsv3
        self.masked = masked
        self.mask_key = mask_key or b''
    
    def to_bytes(self) -> bytes:
        first_byte = (0x80 if self.fin else 0) | self.opcode
        
        if self.payload_length < 126:
            second_byte = (0x80 if self.masked else 0) | self.payload_length
            length_byte = second_byte.to_bytes(1, 'big')
        elif self.payload_length < 65536:
            second_byte = (0x80 if self.masked else 0) | 126
            length_bytes = second_byte.to_bytes(1, 'big') + \
                          self.payload_length.to_bytes(2, 'big')
        else:
            second_byte = (0x80 if self.masked else 0) | 127
            length_bytes = second_byte.to_bytes(1, 'big') + \
                          self.payload_length.to_bytes(8, 'big')
        
        if self.masked and self.mask_key:
            masked_payload = self._mask_payload(self.payload)
            return bytes([first_byte]) + length_bytes + self.mask_key + masked_payload
        
        return bytes([first_byte]) + length_bytes + self.payload
    
    @property
    def payload_length(self) -> int:
        return len(self.payload)
    
    def _mask_payload(self, payload: bytes) -> bytes:
        return bytes(b ^ self.mask_key[i % 4] for i, b in enumerate(payload))

Server-Side Implementation

Python WebSocket Server with asyncio

The reference implementation below is built on Python’s websockets library and the asyncio event loop. It introduces the three core abstractions every real-time server needs: a connection registry, a room membership map, and a message dispatch table. The server tracks every live client in the clients set and groups clients into named rooms, which lets you broadcast to a subset of connections rather than all of them. The message_handlers dictionary decouples protocol parsing from business logic — handlers are registered by message type, so adding a new feature never requires editing the connection loop.

Several design decisions here are worth copying. The broadcast method uses asyncio.gather with return_exceptions=True, which prevents a single slow or failing client from stalling the whole loop; instead of letting one broken socket block delivery to everyone, each send is fired concurrently and errors are swallowed. Connection lifecycle is handled in a try/finally block around the async for message in websocket loop, guaranteeing that unregister always runs even when a client disconnects abruptly mid-stream. Finally, the message handler catches JSONDecodeError and generic exceptions separately so malformed payloads are logged but never crash the server. This pattern — a central loop, typed handlers, and a clean registration/unregistration contract — forms the backbone of most production WebSocket servers:

import asyncio
import websockets
import json
from typing import Set, Dict, Any
import logging
from datetime import datetime

logger = logging.getLogger(__name__)

class WebSocketServer:
    def __init__(self, host: str = "localhost", port: int = 8765):
        self.host = host
        self.port = port
        self.clients: Set[websockets.WebSocketServerProtocol] = set()
        self.rooms: Dict[str, Set[websockets.WebSocketServerProtocol]] = {}
        self.message_handlers: Dict[str, callable] = {}
    
    async def register(self, websocket: websockets.WebSocketServerProtocol):
        self.clients.add(websocket)
        logger.info(f"Client connected: {websocket.remote_address}")
    
    async def unregister(self, websocket: websockets.WebSocketServerProtocol):
        self.clients.discard(websocket)
        
        for room in list(self.rooms.values()):
            room.discard(websocket)
        
        logger.info(f"Client disconnected: {websocket.remote_address}")
    
    async def handle_message(self, websocket: websockets.WebSocketServerProtocol, message: str):
        try:
            data = json.loads(message)
            msg_type = data.get('type', 'unknown')
            
            if msg_type in self.message_handlers:
                await self.message_handlers[msg_type](websocket, data)
            else:
                logger.warning(f"Unknown message type: {msg_type}")
        
        except json.JSONDecodeError:
            logger.error(f"Invalid JSON: {message}")
        except Exception as e:
            logger.error(f"Error handling message: {e}")
    
    async def broadcast(self, message: Dict[str, Any], exclude: Set = None):
        exclude = exclude or set()
        message_str = json.dumps(message)
        
        await asyncio.gather(
            *[
                client.send(message_str)
                for client in self.clients
                if client not in exclude and client.open
            ],
            return_exceptions=True
        )
    
    async def send_to_room(self, room: str, message: Dict[str, Any]):
        if room in self.rooms:
            message_str = json.dumps(message)
            await asyncio.gather(
                *[
                    client.send(message_str)
                    for client in self.rooms[room]
                    if client.open
                ],
                return_exceptions=True
            )
    
    async def join_room(self, websocket: websockets.WebSocketServerProtocol, room: str):
        if room not in self.rooms:
            self.rooms[room] = set()
        self.rooms[room].add(websocket)
        await websocket.send(json.dumps({
            'type': 'room_joined',
            'room': room
        }))
    
    async def leave_room(self, websocket: websockets.WebSocketServerProtocol, room: str):
        if room in self.rooms:
            self.rooms[room].discard(websocket)
    
    async def handler(self, websocket: websockets.WebSocketServerProtocol):
        await self.register(websocket)
        
        try:
            async for message in websocket:
                await self.handle_message(websocket, message)
        
        except websockets.exceptions.ConnectionClosed:
            pass
        finally:
            await self.unregister(websocket)
    
    async def start(self):
        async with websockets.serve(self.handler, self.host, self.port):
            logger.info(f"WebSocket server started on {self.host}:{self.port}")
            await asyncio.Future()
    
    def register_handler(self, msg_type: str, handler: callable):
        self.message_handlers[msg_type] = handler

Message Handler Examples

Handlers are where the server’s generic plumbing meets your application’s domain logic. The ChatMessageHandler class below demonstrates the pattern by registering four handlers: sending a chat message, joining and leaving rooms, and broadcasting typing indicators. Each handler reads whatever fields it needs from the decoded JSON payload, then calls back into the server’s room and broadcast primitives. This separation keeps transport concerns — connection tracking, frame parsing, error handling — inside the core server while keeping chat-specific behavior fully isolated and testable.

Notice that handlers are deliberately small and side-effect free. handle_chat simply validates the room and message, then delegates delivery to send_to_room, which fans the message out to every member. The typing handler does the same with a lightweight payload. There is no persistence here, no rate limiting, and no user authentication — those are deliberately left to middleware and higher layers so that the message flow stays easy to reason about. In a production system you would add spam filtering, message history, and per-user identity resolution at this layer, but the shape of the code would remain unchanged:

class ChatMessageHandler:
    def __init__(self, server: WebSocketServer):
        self.server = server
        self._register_handlers()
    
    def _register_handlers(self):
        self.server.register_handler('chat_message', self.handle_chat)
        self.server.register_handler('join_room', self.handle_join_room)
        self.server.register_handler('leave_room', self.handle_leave_room)
        self.server.register_handler('typing', self.handle_typing)
    
    async def handle_chat(self, websocket, data: Dict):
        room = data.get('room')
        message = data.get('message')
        username = data.get('username', 'Anonymous')
        
        await self.server.send_to_room(room, {
            'type': 'chat_message',
            'username': username,
            'message': message,
            'timestamp': datetime.utcnow().isoformat()
        })
    
    async def handle_join_room(self, websocket, data: Dict):
        room = data.get('room')
        await self.server.join_room(websocket, room)
        
        await self.server.send_to_room(room, {
            'type': 'user_joined',
            'room': room,
            'timestamp': datetime.utcnow().isoformat()
        })
    
    async def handle_leave_room(self, websocket, data: Dict):
        room = data.get('room')
        await self.server.leave_room(websocket, room)
    
    async def handle_typing(self, websocket, data: Dict):
        room = data.get('room')
        username = data.get('username')
        
        await self.server.send_to_room(room, {
            'type': 'typing',
            'username': username
        })

Node.js WebSocket Server

JavaScript developers will recognize the same architecture in the Node.js implementation below, built on the ubiquitous ws library. Instead of Python’s async/await, this server leans on Node’s event-driven model: the ws module emits connection, message, close, and error events, and the class wires each of those events to a handler method. Connections are tracked in a Map keyed by a generated client ID, and rooms are stored as nested maps so each client can belong to many rooms simultaneously. The client ID is essential — unlike Python, where the socket object itself serves as identity, the Map-based approach lets you reference clients without holding raw socket references.

There are two notable differences from the asyncio version. First, JSON parsing happens inside a try/catch in handleMessage, and unknown message types fall through to a default case rather than raising — a defensive style that keeps a single malformed frame from taking down the event loop. Second, every send is guarded by a readyState === WebSocket.OPEN check, because in Node a socket can be closing while messages are still queued; attempting to write to a closing socket throws. This explicit state checking is the single most common source of Node WebSocket bugs, and guarding every send is the recommended mitigation:

const WebSocket = require('ws');

class WebSocketServer {
    constructor(port) {
        this.port = port;
        this.wss = new WebSocket.Server({ port });
        this.clients = new Map();
        this.rooms = new Map();
        
        this.wss.on('connection', this.handleConnection.bind(this));
    }
    
    handleConnection(ws, req) {
        const clientId = this.generateClientId();
        this.clients.set(clientId, { ws, rooms: new Set() });
        
        console.log(`Client connected: ${clientId}`);
        
        ws.on('message', (message) => {
            this.handleMessage(clientId, message);
        });
        
        ws.on('close', () => {
            this.handleDisconnect(clientId);
        });
        
        ws.on('error', (error) => {
            console.error(`WebSocket error: ${error}`);
        });
        
        this.send(clientId, { type: 'connected', clientId });
    }
    
    handleMessage(clientId, rawMessage) {
        try {
            const message = JSON.parse(rawMessage);
            
            switch (message.type) {
                case 'chat_message':
                    this.handleChatMessage(clientId, message);
                    break;
                case 'join_room':
                    this.handleJoinRoom(clientId, message);
                    break;
                case 'leave_room':
                    this.handleLeaveRoom(clientId, message);
                    break;
                case 'broadcast':
                    this.handleBroadcast(clientId, message);
                    break;
                default:
                    console.log(`Unknown message type: ${message.type}`);
            }
        } catch (error) {
            console.error('Error handling message:', error);
        }
    }
    
    handleChatMessage(clientId, message) {
        const room = message.room;
        const roomClients = this.rooms.get(room);
        
        if (roomClients) {
            const messageData = {
                type: 'chat_message',
                clientId,
                message: message.content,
                timestamp: new Date().toISOString()
            };
            
            roomClients.forEach(client => {
                if (client.ws.readyState === WebSocket.OPEN) {
                    client.ws.send(JSON.stringify(messageData));
                }
            });
        }
    }
    
    handleJoinRoom(clientId, message) {
        const room = message.room;
        
        if (!this.rooms.has(room)) {
            this.rooms.set(room, new Map());
        }
        
        const client = this.clients.get(clientId);
        const roomClients = this.rooms.get(room);
        
        client.rooms.add(room);
        roomClients.set(clientId, client);
        
        this.send(clientId, { type: 'joined_room', room });
    }
    
    handleLeaveRoom(clientId, message) {
        const room = message.room;
        
        if (this.rooms.has(room)) {
            this.rooms.get(room).delete(clientId);
        }
        
        const client = this.clients.get(clientId);
        client.rooms.delete(room);
    }
    
    handleBroadcast(clientId, message) {
        const client = this.clients.get(clientId);
        
        this.clients.forEach((c, id) => {
            if (id !== clientId && c.ws.readyState === WebSocket.OPEN) {
                c.ws.send(JSON.stringify({
                    type: 'broadcast',
                    from: clientId,
                    message: message.content
                }));
            }
        });
    }
    
    handleDisconnect(clientId) {
        const client = this.clients.get(clientId);
        
        client.rooms.forEach(room => {
            if (this.rooms.has(room)) {
                this.rooms.get(room).delete(clientId);
            }
        });
        
        this.clients.delete(clientId);
        console.log(`Client disconnected: ${clientId}`);
    }
    
    send(clientId, message) {
        const client = this.clients.get(clientId);
        if (client && client.ws.readyState === WebSocket.OPEN) {
            client.ws.send(JSON.stringify(message));
        }
    }
    
    generateClientId() {
        return `client_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
    }
    
    start() {
        this.wss.listen(this.port, () => {
            console.log(`WebSocket server started on port ${this.port}`);
        });
    }
}

module.exports = WebSocketServer;

Client-Side Implementation

JavaScript WebSocket Client

A robust client is just as important as a robust server. The WebSocketClient class below wraps the browser’s native WebSocket object behind a small, reusable API that handles the two concerns every real-time client must manage: automatic reconnection and decoupled event handling. When the connection closes unexpectedly, onclose fires attemptReconnect, which retries up to maxReconnectAttempts times with a configurable delay between attempts. Notice that successful connections reset the attempt counter, so a long-lived session does not inherit the failure history of an earlier one.

The handler registry (open, close, error, message) is another important abstraction. Instead of scattering ws.onmessage callbacks throughout your application, the client collects listeners and notifies them on every event, mirroring the API of popular libraries. Incoming messages are parsed as JSON when possible and passed through raw otherwise, so both structured events and arbitrary payloads work with the same registration API. The WebSocketManager class that follows takes this one step further: it maintains multiple named clients and routes messages to per-client handlers, which is exactly what you need for apps that hold several simultaneous connections — for example, one for chat and one for presence updates:

class WebSocketClient {
    constructor(url, options = {}) {
        this.url = url;
        this.reconnectInterval = options.reconnectInterval || 1000;
        this.maxReconnectAttempts = options.maxReconnectAttempts || 5;
        this.reconnectAttempts = 0;
        
        this.handlers = {
            open: [],
            close: [],
            error: [],
            message: []
        };
        
        this.connect();
    }
    
    connect() {
        this.ws = new WebSocket(this.url);
        
        this.ws.onopen = (event) => {
            console.log('WebSocket connected');
            this.reconnectAttempts = 0;
            this.handlers.open.forEach(handler => handler(event));
        };
        
        this.ws.onclose = (event) => {
            console.log('WebSocket closed');
            this.handlers.close.forEach(handler => handler(event));
            this.attemptReconnect();
        };
        
        this.ws.onerror = (event) => {
            console.error('WebSocket error:', event);
            this.handlers.error.forEach(handler => handler(event));
        };
        
        this.ws.onmessage = (event) => {
            try {
                const data = JSON.parse(event.data);
                this.handlers.message.forEach(handler => handler(data));
            } catch (e) {
                this.handlers.message.forEach(handler => handler(event.data));
            }
        };
    }
    
    attemptReconnect() {
        if (this.reconnectAttempts < this.maxReconnectAttempts) {
            this.reconnectAttempts++;
            console.log(`Attempting reconnect (${this.reconnectAttempts}/${this.maxReconnectAttempts})`);
            setTimeout(() => this.connect(), this.reconnectInterval);
        } else {
            console.error('Max reconnection attempts reached');
        }
    }
    
    send(data) {
        if (this.ws && this.ws.readyState === WebSocket.OPEN) {
            const message = typeof data === 'string' ? data : JSON.stringify(data);
            this.ws.send(message);
        } else {
            console.error('WebSocket is not connected');
        }
    }
    
    on(event, handler) {
        if (this.handlers[event]) {
            this.handlers[event].push(handler);
        }
    }
    
    off(event, handler) {
        if (this.handlers[event]) {
            const index = this.handlers[event].indexOf(handler);
            if (index > -1) {
                this.handlers[event].splice(index, 1);
            }
        }
    }
    
    close() {
        if (this.ws) {
            this.ws.close();
        }
    }
    
    readyState() {
        return this.ws ? this.ws.readyState : WebSocket.CLOSED;
    }
}

class WebSocketManager {
    constructor() {
        this.clients = new Map();
        this.messageHandlers = new Map();
    }
    
    createClient(id, url, options) {
        const client = new WebSocketClient(url, options);
        
        client.on('message', (data) => {
            const handler = this.messageHandlers.get(id);
            if (handler) {
                handler(data);
            }
        });
        
        this.clients.set(id, client);
        return client;
    }
    
    sendToClient(id, data) {
        const client = this.clients.get(id);
        if (client) {
            client.send(data);
        }
    }
    
    broadcast(data) {
        this.clients.forEach(client => {
            client.send(data);
        });
    }
    
    removeClient(id) {
        const client = this.clients.get(id);
        if (client) {
            client.close();
            this.clients.delete(id);
        }
    }
    
    onMessage(id, handler) {
        this.messageHandlers.set(id, handler);
    }
}

React WebSocket Hook

In a React application, imperative WebSocket logic clashes with the framework’s declarative, render-driven model. The useWebSocket hook below bridges that gap by wrapping the client in a hook that returns state and callback functions. Connection state, the latest message, and any error are exposed as React state — so components re-render automatically when the socket opens, closes, or delivers data — while sendMessage, disconnect, and reconnect are memoized with useCallback so they have stable identities across renders.

The hook also demonstrates two React-specific best practices. First, optionsRef holds the options object in a ref so that callbacks registered during the initial render can read the latest option values without re-creating the WebSocket connection — this prevents the classic stale-closure bug where handlers capture outdated props. Second, the effect that calls connect returns a cleanup function that closes the socket, which means the connection is torn down whenever the component unmounts or the URL changes. This prevents memory leaks and duplicate connections, a subtle failure mode that plagues naive WebSocket usage in React. Reconnection is handled inside onclose when wasClean is false, giving you automatic recovery after network blips without any app-level bookkeeping:

import { useState, useEffect, useRef, useCallback } from 'react';

const useWebSocket = (url, options = {}) => {
    const [isConnected, setIsConnected] = useState(false);
    const [lastMessage, setLastMessage] = useState(null);
    const [error, setError] = useState(null);
    
    const wsRef = useRef(null);
    const optionsRef = useRef(options);
    
    const connect = useCallback(() => {
        if (wsRef.current?.readyState === WebSocket.OPEN) {
            return;
        }
        
        const ws = new WebSocket(url);
        
        ws.onopen = () => {
            setIsConnected(true);
            setError(null);
            optionsRef.current.onOpen?.();
        };
        
        ws.onclose = (event) => {
            setIsConnected(false);
            optionsRef.current.onClose?.(event);
            
            if (!event.wasClean && optionsRef.current.reconnect) {
                setTimeout(connect, optionsRef.current.reconnectInterval || 3000);
            }
        };
        
        ws.onerror = (event) => {
            setError(event);
            optionsRef.current.onError?.(event);
        };
        
        ws.onmessage = (event) => {
            const message = optionsRef.current.parseMessage?.(event.data) || event.data;
            setLastMessage(message);
            optionsRef.current.onMessage?.(message);
        };
        
        wsRef.current = ws;
    }, [url]);
    
    useEffect(() => {
        connect();
        
        return () => {
            if (wsRef.current) {
                wsRef.current.close();
            }
        };
    }, [connect]);
    
    const sendMessage = useCallback((message) => {
        if (wsRef.current?.readyState === WebSocket.OPEN) {
            const data = optionsRef.current.stringifyMessage?.(message) || message;
            wsRef.current.send(data);
        }
    }, []);
    
    const disconnect = useCallback(() => {
        if (wsRef.current) {
            wsRef.current.close();
        }
    }, []);
    
    return {
        isConnected,
        lastMessage,
        error,
        sendMessage,
        disconnect,
        reconnect: connect
    };
};

export default useWebSocket;

Protocol Details

Subprotocols

WebSocket subprotocols allow you to define the semantics of messages. Because the raw protocol is deliberately agnostic — it transports bytes, not meaning — two applications that speak WebSocket to each other must agree on what those bytes mean. A subprotocol is simply a name, negotiated during the handshake, that binds both sides to a shared contract. The server includes a Sec-WebSocket-Protocol header listing the protocols it supports, the client picks one it understands, and both sides commit to using it for the life of the connection.

The example below models that negotiation in Python. WebSocketSubprotocol enumerates the supported protocols as versioned constants — the v1 suffix is a common convention for evolving message formats without breaking existing clients. The handler class then maps each protocol to a dedicated method, so a connection negotiated as chat.v1 routes to handle_chat while a realtime.v1 connection routes elsewhere. This is especially valuable when a single server serves multiple product surfaces with very different message shapes. In practice, libraries like MQTT-over-WebSocket use exactly this mechanism to layer a publish/subscribe protocol on top of the generic transport:

class WebSocketSubprotocol:
    CHAT = "chat.v1"
    NOTIFICATIONS = "notifications.v1"
    Realtime_API = "realtime.v1"
    MQTT = "mqtt"

class WebSocketSubprotocolHandler:
    def __init__(self, protocol: str):
        self.protocol = protocol
        self.handlers = {
            WebSocketSubprotocol.CHAT: self.handle_chat,
            WebSocketSubprotocol.NOTIFICATIONS: self.handle_notification,
            WebSocketSubprotocol.Realtime_API: self.handle_realtime,
        }
    
    async def handle_message(self, websocket, message: dict):
        handler = self.handlers.get(self.protocol)
        if handler:
            await handler(websocket, message)
    
    async def handle_chat(self, websocket, message: dict):
        # Chat-specific handling
        pass
    
    async def handle_notification(self, websocket, message: dict):
        # Notification-specific handling
        pass
    
    async def handle_realtime(self, websocket, message: dict):
        # Real-time API handling
        pass

Extensions

WebSocket extensions provide additional capabilities negotiated during the handshake. Where subprotocols define what messages mean, extensions change how bytes are transmitted. The most important one in production is permessage-deflate, which compresses each message with DEFLATE before it hits the wire and decompresses it on arrival. On JSON-heavy traffic this routinely cuts payload size by 60–80%, and for text-heavy chat or notification systems the savings translate directly into lower bandwidth costs and faster perceived performance. Because compression is negotiated per-connection, clients that do not support it simply receive uncompressed frames — the negotiation is what keeps old clients working.

The CompressionHandler class below shows the two halves of the feature. enable_compression produces the handshake header that advertises support, including the optional client_max_window_bits and server_max_window_bits parameters that tune the compression window size. compress_message and decompress_message are the actual encode/decode points — in a real implementation these would call the zlib module and manage per-connection DEFLATE contexts, because WebSocket compression uses a streaming context that persists across messages. The trade-off to weigh is CPU: compression buys bandwidth at the expense of one extra deflate pass per message, so very high-throughput servers sometimes disable it in favor of lighter payloads or Protocol Buffers encoding:

class WebSocketExtensions:
    PERMESSAGE_DEFLATE = "permessage-deflate"
    MULTIPLEXING = "websocket-extensions"

class CompressionHandler:
    def __init__(self):
        self.compressor = None
    
    def enable_compression(self):
        return {
            'sec-websocket-extensions': 'permessage-deflate; ' \
                                       'client_max_window_bits; ' \
                                       'server_max_window_bits=15'
        }
    
    def compress_message(self, data: bytes) -> bytes:
        # Compression logic
        return data
    
    def decompress_message(self, data: bytes) -> bytes:
        # Decompression logic
        return data

Security

Authentication

Because the WebSocket handshake is just an HTTP request, authentication has a well-defined place: it happens before or during the handshake, before any application frames flow. The WebSocketAuthenticator class below implements a stateless signed-token scheme. On login, the server generates a token by concatenating the user ID and an expiry timestamp with a colon, then signs the result with an HMAC-SHA256 digest using a shared secret key. The token travels with the connection, and verify_token recomputes the signature and compares it with hmac.compare_digest — a constant-time comparison that prevents timing attacks on the signature.

The key insight is that the token is self-contained: the server does not need to store session state, because the token itself carries the user identity and expiry, protected by a signature the client cannot forge. The middleware wrapper below shows how to enforce this at the connection boundary. It reads the token from the handshake headers, closes the connection with a custom close code (4001 for missing credentials, 4002 for invalid ones) when verification fails, and only then invokes the wrapped application handler. Using distinct close codes lets clients surface precise error messages. The main trade-off is secret management: the signing key must be kept out of client code and rotated periodically, and token expiry should be short enough that a leaked token has a limited window of usefulness:

import secrets
import hashlib
import hmac
from typing import Optional

class WebSocketAuthenticator:
    def __init__(self, secret_key: str):
        self.secret_key = secret_key
    
    def generate_token(self, user_id: str, expires_in: int = 3600) -> str:
        expiry = int(time.time()) + expires_in
        payload = f"{user_id}:{expiry}"
        
        signature = hmac.new(
            self.secret_key.encode(),
            payload.encode(),
            hashlib.sha256
        ).hexdigest()
        
        return f"{payload}:{signature}"
    
    def verify_token(self, token: str) -> Optional[str]:
        try:
            user_id, expiry, signature = token.rsplit(':', 2)
            
            expected_signature = hmac.new(
                self.secret_key.encode(),
                f"{user_id}:{expiry}".encode(),
                hashlib.sha256
            ).hexdigest()
            
            if not hmac.compare_digest(signature, expected_signature):
                return None
            
            if int(expiry) < int(time.time()):
                return None
            
            return user_id
        
        except ValueError:
            return None

class WebSocketAuthMiddleware:
    def __init__(self, app, auth: WebSocketAuthenticator):
        self.app = app
        self.auth = auth
    
    async def __call__(self, websocket, path):
        token = websocket.request_headers.get('Sec-WebSocket-Protocol')
        
        if not token:
            await websocket.close(4001, "Authentication required")
            return
        
        user_id = self.auth.verify_token(token)
        
        if not user_id:
            await websocket.close(4002, "Invalid token")
            return
        
        websocket.user_id = user_id
        
        await self.app(websocket, path)

Origin Validation

The Origin header is the WebSocket equivalent of checking who is knocking on the door. Browsers send the origin of the page that initiated the connection, and validating it is the primary defense against cross-site WebSocket hijacking — a class of attack where a malicious page triggers a connection to your server with the victim’s cookies. The OriginValidator class below maintains a whitelist of allowed origins and rejects anything else, closing the connection with code 4003 before any application data is exchanged.

The implementation keeps the policy simple and explicit. An empty origin is always rejected, since legitimate browsers virtually always send the header. A wildcard entry bypasses the check entirely — a useful escape hatch for non-browser clients like native apps and CLI tools that do not send an Origin header, but one that should never be enabled for browser-facing endpoints. Beyond origin checks, production servers should also validate the Host header and combine origin validation with authentication; origin checking alone stops cross-site attacks but does nothing against a direct attacker who simply omits the header:

class OriginValidator:
    def __init__(self, allowed_origins: list):
        self.allowed_origins = allowed_origins
    
    async def validate(self, origin: str) -> bool:
        if not origin:
            return False
        
        if "*" in self.allowed_origins:
            return True
        
        return origin in self.allowed_origins
    
    async def handle_request(self, websocket, path):
        origin = websocket.request_headers.get('Origin')
        
        if not await self.validate(origin):
            await websocket.close(4003, "Origin not allowed")
            return False
        
        return True

Secure Connections

All of the security in this chapter is pointless if the traffic is transmitted in the clear. WSS — WebSocket over TLS — is the same protocol running inside an encrypted tunnel, and it is non-negotiable for production: the WebSocket spec explicitly warns that insecure ws:// connections expose message content to any network observer, which includes every public Wi-Fi hotspot and, in many environments, the network operator itself. The WebSocketSSLConfig class below shows the minimal setup: load a certificate chain into a TLS server context and hand that context to the WebSocket server library, which transparently upgrades every handshake to TLS.

Two details in the configuration are worth attention. The cipher suite string — ECDHE+AESGCM:ECDHE+CHACHA20:DHE+AESGCM:DHE+CHACHA20 — prefers forward-secret, authenticated ciphers and deliberately excludes older suites like CBC mode and RC4 that have known weaknesses. And because this is a server-side concern, the certificate and private key are loaded from files on the server filesystem; they must never be embedded in client code. For mobile or desktop clients, the corresponding step is validating the server’s certificate chain through the OS trust store, and for highly sensitive endpoints, pinning the server’s public key. TLS termination can also be offloaded to a reverse proxy or load balancer, which simplifies certificate management when you have many services:

import ssl

class WebSocketSSLConfig:
    @staticmethod
    def create_ssl_context(cert_file: str, key_file: str) -> ssl.SSLContext:
        context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
        context.load_cert_chain(cert_file, key_file)
        context.set_ciphers('ECDHE+AESGCM:ECDHE+CHACHA20:DHE+AESGCM:DHE+CHACHA20')
        
        return context
    
    @staticmethod
    def create_wss_config(cert_file: str, key_file: str) -> dict:
        ssl_context = WebSocketSSLConfig.create_ssl_context(cert_file, key_file)
        
        return {
            'ssl': ssl_context
        }

Scaling WebSockets

Connection Management

WebSocket connections are long-lived and resource-hungry compared to HTTP requests: every open socket holds a file descriptor, a socket buffer, and server memory for the lifetime of the connection. The WebSocketConnectionPool class below formalizes connection management with an explicit upper bound and a lock-protected registry. max_connections caps how many concurrent sockets the server will accept — raising an error once the limit is reached rather than silently exhausting file descriptors. Every mutation of the connection and room maps runs inside asyncio.Lock, which matters because asyncio code can interleave at any await point and two coroutines updating the same dictionary concurrently is a real correctness hazard.

The pool also centralizes the tricky part of state consistency: when a connection is removed, remove_connection first walks the connection’s room memberships and leaves each one before deleting the connection record. This guarantees the room maps never contain dangling references to dead sockets — a subtle bug that otherwise leaks rooms and causes broadcasts to hit nonexistent connections. Exposing get_connection_count and get_room_count as methods is deliberate: these are the metrics you will want to feed into monitoring, since connection count growth over time is the earliest signal that something is leaking sockets or that load is outgrowing the instance:

import asyncio
from typing import Dict, List

class WebSocketConnectionPool:
    def __init__(self, max_connections: int = 10000):
        self.max_connections = max_connections
        self.connections: Dict[str, WebSocketConnection] = {}
        self.rooms: Dict[str, set] = {}
        self._lock = asyncio.Lock()
    
    async def add_connection(self, connection_id: str, websocket):
        async with self._lock:
            if len(self.connections) >= self.max_connections:
                raise Exception("Connection pool full")
            
            self.connections[connection_id] = WebSocketConnection(
                id=connection_id,
                websocket=websocket
            )
    
    async def remove_connection(self, connection_id: str):
        async with self._lock:
            if connection_id in self.connections:
                connection = self.connections[connection_id]
                
                for room in list(connection.rooms):
                    await self.leave_room(connection_id, room)
                
                del self.connections[connection_id]
    
    async def join_room(self, connection_id: str, room: str):
        async with self._lock:
            if connection_id not in self.connections:
                return
            
            if room not in self.rooms:
                self.rooms[room] = set()
            
            self.rooms[room].add(connection_id)
            self.connections[connection_id].rooms.add(room)
    
    async def leave_room(self, connection_id: str, room: str):
        async with self._lock:
            if room in self.rooms:
                self.rooms[room].discard(connection_id)
            
            if connection_id in self.connections:
                self.connections[connection_id].rooms.discard(room)
    
    async def broadcast_to_room(self, room: str, message: bytes):
        if room not in self.rooms:
            return
        
        await asyncio.gather(
            *[
                self.connections[conn_id].send(message)
                for conn_id in self.rooms[room]
                if conn_id in self.connections
            ],
            return_exceptions=True
        )
    
    async def get_connection_count(self) -> int:
        async with self._lock:
            return len(self.connections)
    
    async def get_room_count(self, room: str) -> int:
        async with self._lock:
            return len(self.rooms.get(room, set()))

@dataclass
class WebSocketConnection:
    id: str
    websocket: any
    rooms: set = field(default_factory=set)
    metadata: dict = field(default_factory=dict)

Horizontal Scaling with Redis

A single server instance can hold only so many connections, and the connection pool above keeps those sockets tied to one process. To scale horizontally, multiple WebSocket servers must cooperate as if they were one: when a chat message arrives at server A, every client connected to server B must also receive it. The standard answer is a message broker, and Redis is the most common choice because its pub/sub model is exactly the right shape. The RedisWebSocketManager below uses Redis for two purposes — as a directory that records which server owns which connection, and as a bus that routes messages between servers.

The first set of methods maintains that directory: register_connection stores the connection’s owner server in a hash and adds it to a per-server set, while unregister_connection cleans up both records. This is how server B can look up which server holds a given connection — get_connection_server reads the hash — and forward a targeted message accordingly. The second set is the pub/sub half: each server subscribes to the channels for its clients and publishes JSON-serialized messages to them, re-broadcasting anything it receives to its local socket connections. The architecture keeps each WebSocket server’s sockets local while the broker handles cross-server delivery. The trade-off is one extra network hop and broker availability: Redis becomes critical infrastructure, so production deployments pair it with high availability, and many teams layer in a queuing system (Redis Streams or Kafka) when they also need message durability and replay:

import redis.asyncio as redis
import json
from typing import Optional

class RedisWebSocketManager:
    def __init__(self, redis_url: str):
        self.redis = redis.from_url(redis_url)
        self.pubsub = self.redis.pubsub()
    
    async def register_connection(self, connection_id: str, server_id: str):
        await self.redis.sadd(f"server:{server_id}:connections", connection_id)
        await self.redis.hset(
            f"connection:{connection_id}",
            mapping={
                "server_id": server_id,
                "connected_at": str(time.time())
            }
        )
    
    async def unregister_connection(self, connection_id: str):
        server_id = await self.redis.hget(f"connection:{connection_id}", "server_id")
        
        if server_id:
            await self.redis.srem(f"server:{server_id.decode()}:connections", connection_id)
        
        await self.redis.delete(f"connection:{connection_id}")
    
    async def publish_message(self, channel: str, message: dict):
        await self.redis.publish(channel, json.dumps(message))
    
    async def subscribe_to_channel(self, channel: str):
        await self.pubsub.subscribe(channel)
    
    async def get_connection_server(self, connection_id: str) -> Optional[str]:
        server_id = await self.redis.hget(f"connection:{connection_id}", "server_id")
        return server_id.decode() if server_id else None
    
    async def get_all_server_ids(self) -> List[str]:
        servers = await self.redis.keys("server:*:connections")
        return [s.decode().split(":")[1] for s in servers]

Best Practices

Connection Handling

The code throughout this guide is easier to use correctly when you have a checklist in mind, and the dictionary below condenses the operational wisdom from years of production WebSocket systems into five categories. The connection lifecycle rules attack the most common failure modes: heartbeat frames detect half-open connections where one side has silently vanished, timeouts bound how long a connection can linger, and client-side reconnection makes the app resilient to brief network interruptions and server restarts. Message handling rules center on validation and acknowledgment, because a server that trusts its input without question is one bug away from a crash or an injection.

The security and performance lists reflect the harder-won lessons. Never serve plain ws:// in production, authenticate during the handshake rather than after, and rate-limit per connection — otherwise a single hostile client can monopolize the server. On the performance side, batching multiple events into a single frame and using a compact binary encoding like Protocol Buffers or MessagePack can dramatically reduce per-message overhead at high throughput. Scalability rules are about designing for growth before you need it: building on a broker (Redis pub/sub) and instrumenting connection metrics from day one means you are never forced into an emergency redesign when traffic spikes:

BEST_PRACTICES = {
    "connection_lifecycle": [
        "Implement heartbeat/ping-pong to detect stale connections",
        "Set appropriate connection timeouts",
        "Handle reconnection gracefully on client side",
        "Clean up resources on disconnect",
        "Limit concurrent connections per user/IP"
    ],
    
    "message_handling": [
        "Always validate and sanitize incoming messages",
        "Use message type dispatching for organization",
        "Implement message acknowledgment for critical operations",
        "Handle message queuing during brief disconnections",
        "Use binary protocol for large messages"
    ],
    
    "security": [
        "Always use WSS for production",
        "Implement authentication during handshake",
        "Validate Origin header",
        "Implement rate limiting per connection",
        "Sanitize all user input"
    ],
    
    "performance": [
        "Use connection pooling where applicable",
        "Implement message batching for high throughput",
        "Compress messages when possible",
        "Monitor connection state and health",
        "Use efficient serialization (Protocol Buffers, MessagePack)"
    ],
    
    "scalability": [
        "Design for horizontal scaling from the start",
        "Use Redis pub/sub for cross-server communication",
        "Implement sticky sessions or message routing",
        "Monitor and alert on connection metrics",
        "Plan for graceful degradation under load"
    ]
}

Error Handling

Errors in a WebSocket system fall into two buckets — connection-level failures and message-level failures — and they demand different responses. A dead or dying connection cannot be meaningfully recovered in place, so the connection error handler below logs the failure and attempts to close the socket with status code 1011 (“internal server error”), a notification to the client that something went wrong server-side. Crucially, the close itself is wrapped in a try/except, because by the time you are handling a connection error the socket may already be unusable, and throwing again would mask the original problem.

Message-level errors are handled differently because the connection is still healthy. Rather than dropping the failure silently, the handler sends a structured error frame back to the client containing the original message_id and the error text. That correlation identifier is the key design point: without it, a client that sent dozens of rapid messages cannot tell which operation failed, and the whole protocol becomes nearly impossible to debug. Sending error frames on the same channel also keeps the client’s error handling uniform — the same message event handler can branch on the type field to distinguish normal events from error reports. For timeouts, a distinct timeout message type lets clients implement their own retry logic without conflating timeout with a hard failure:

class WebSocketErrorHandler:
    @staticmethod
    async def handle_connection_error(websocket, error):
        logger.error(f"Connection error: {error}")
        
        try:
            await websocket.close(1011, "Internal server error")
        except:
            pass
    
    @staticmethod
    async def handle_message_error(websocket, message_id, error):
        logger.error(f"Message error ({message_id}): {error}")
        
        await websocket.send(json.dumps({
            'type': 'error',
            'message_id': message_id,
            'error': str(error)
        }))
    
    @staticmethod
    async def handle_timeout_error(websocket, operation):
        logger.warning(f"Timeout during {operation}")
        
        await websocket.send(json.dumps({
            'type': 'timeout',
            'operation': operation
        }))

Testing WebSockets

Server Testing

WebSocket code is notoriously under-tested because the protocol is stateful and asynchronous, but the discipline pays off immediately — connection handling, room membership, and reconnection logic are exactly the code most likely to regress. The tests below use pytest with its asyncio plugin, which lets each test open real WebSocket connections against a running server and drive the full protocol stack, including the handshake and frame encoding. This is integration testing at the protocol level: because the websockets client library performs a genuine handshake, the tests validate your server’s wire behavior, not just its internal methods.

The three tests cover the essential behaviors. The first confirms a connection can be established and that a ping message elicits the expected pong response — a smoke test that catches broken startup or miswired handlers. The second is the most valuable: it opens two clients, has one join a room and send a message, and asserts the other receives it. This single test validates room membership, message routing, and broadcast delivery end to end, which is where the overwhelming majority of WebSocket bugs actually live. The third verifies reconnection logic using a wait-timeout, confirming the client recovers from a dropped connection. A useful extension pattern is to run these against a Dockerized server in CI and to add negative tests for malformed JSON, unauthorized connections, and connection limits:

import pytest
import asyncio
import websockets

@pytest.mark.asyncio
async def test_websocket_connection():
    async with websockets.connect("ws://localhost:8765") as websocket:
        await websocket.send('{"type": "ping"}')
        
        response = await asyncio.wait_for(websocket.recv(), timeout=5)
        data = json.loads(response)
        
        assert data['type'] == 'pong'

@pytest.mark.asyncio
async def test_websocket_chat():
    async with websockets.connect("ws://localhost:8765") as ws1:
        async with websockets.connect("ws://localhost:8765") as ws2:
            await ws1.send(json.dumps({
                'type': 'join_room',
                'room': 'test-room'
            }))
            
            response = await ws1.recv()
            assert json.loads(response)['type'] == 'room_joined'
            
            await ws1.send(json.dumps({
                'type': 'chat_message',
                'room': 'test-room',
                'message': 'Hello'
            }))
            
            response = await ws2.recv()
            data = json.loads(response)
            
            assert data['type'] == 'chat_message'
            assert data['message'] == 'Hello'

@pytest.mark.asyncio
async def test_websocket_reconnection():
    client = WebSocketClient("ws://localhost:8765", {
        'reconnect': True,
        'reconnectInterval': 1000,
        'maxReconnectAttempts': 3
    })
    
    await asyncio.sleep(2)
    
    assert client.readyState() == WebSocket.OPEN

Use Cases

Live Notifications

Push-style notifications are one of the most common production uses of WebSockets. The NotificationServer class below reuses the room machinery built earlier in a natural way: every user subscribes to a set of category rooms, and the server pushes notifications into those rooms as they occur. Note the room naming convention — notifications:<category> and notifications:<user_id> — which is a simple but powerful idea. By deriving room names from subscription keys, the same join_room/send_to_room primitives serve both topic-based fan-out (a category broadcast) and targeted delivery (a per-user message) with zero extra machinery.

The design highlights an important tension: fan-out is cheap, but subscription management must be deliberate. Because each user joins potentially many rooms, the subscribe handler iterates the requested categories and joins each one; the unsubscribe handler is the mirror image. In production this pattern typically adds a persistence layer — Redis or a database records which categories a user subscribes to so the state survives reconnects, and missed notifications can be replayed on reconnect. Delivery semantics here are at-most-once; for financial or transactional updates you would layer on message IDs and client-side deduplication to guarantee exactly-once delivery to the UI:

class NotificationServer:
    def __init__(self, server: WebSocketServer):
        self.server = server
        self._register_handlers()
    
    def _register_handlers(self):
        self.server.register_handler('subscribe', self.handle_subscribe)
        self.server.register_handler('unsubscribe', self.handle_unsubscribe)
    
    async def handle_subscribe(self, websocket, data: Dict):
        user_id = data.get('user_id')
        categories = data.get('categories', ['all'])
        
        for category in categories:
            room = f"notifications:{category}"
            await self.server.join_room(websocket, room)
        
        await websocket.send(json.dumps({
            'type': 'subscribed',
            'categories': categories
        }))
    
    async def send_notification(self, user_id: str, notification: Dict):
        await self.server.send_to_room(f"notifications:{user_id}", {
            'type': 'notification',
            'notification': notification,
            'timestamp': datetime.utcnow().isoformat()
        })
    
    async def broadcast_notification(self, category: str, notification: Dict):
        await self.server.send_to_room(f"notifications:{category}", {
            'type': 'notification',
            'notification': notification,
            'timestamp': datetime.utcnow().isoformat()
        })

Collaborative Editing

Real-time collaboration is where WebSockets show their full value, and the CollaborativeEditor class below is a compact model of how a shared document server works. Each document is exposed as a room (doc:<document_id>), and opening a document joins the client to that room and immediately sends the current content plus the cursors of everyone else currently viewing it. When one user types, the edit is applied to the server’s copy of the document and then broadcast to every other member of the room, so all participants converge on the same state without any client-side coordination.

This implementation makes the classic simplification: it applies whole-document insert operations and broadcasts them directly, which is correct for two users but degrades under real concurrency. When two users edit the same region simultaneously, the last write wins and the other user’s characters are lost. Production collaborative editors solve this with operational transformation (OT) or Conflict-Free Replicated Data Types (CRDTs) — the reference implementations are the open-source yjs and automerge libraries — which merge concurrent edits at the character level. The other production concerns this model omits are edit validation, version/sequence numbering so clients can detect missed updates, presence timeouts for stale cursors, and server-side persistence so the document survives server restarts. The core architecture, however — a room per document, broadcast on edit, and cursor presence relayed through the same channel — is exactly what the big collaborative editors run in production:

class CollaborativeEditor:
    def __init__(self, server: WebSocketServer):
        self.server = server
        self.documents: Dict[str, str] = {}
        self.cursors: Dict[str, Dict] = {}
        self._register_handlers()
    
    def _register_handlers(self):
        self.server.register_handler('open_document', self.handle_open)
        self.server.register_handler('edit_document', self.handle_edit)
        self.server.register_handler('cursor_move', self.handle_cursor)
        self.server.register_handler('close_document', self.handle_close)
    
    async def handle_open(self, websocket, data: Dict):
        doc_id = data.get('document_id')
        
        await self.server.join_room(websocket, f"doc:{doc_id}")
        
        content = self.documents.get(doc_id, "")
        
        await websocket.send(json.dumps({
            'type': 'document_content',
            'document_id': doc_id,
            'content': content,
            'cursors': self._get_cursors(doc_id)
        }))
    
    async def handle_edit(self, websocket, data: Dict):
        doc_id = data.get('document_id')
        edit = data.get('edit')
        
        self._apply_edit(doc_id, edit)
        
        await self.server.send_to_room(f"doc:{doc_id}", {
            'type': 'document_edit',
            'document_id': doc_id,
            'edit': edit,
            'editor': websocket.remote_address
        })
    
    async def handle_cursor(self, websocket, data: Dict):
        doc_id = data.get('document_id')
        position = data.get('position')
        
        self.cursors[f"{doc_id}:{websocket.remote_address}"] = position
    
    def _apply_edit(self, doc_id: str, edit: Dict):
        if doc_id not in self.documents:
            self.documents[doc_id] = ""
        
        operation = edit.get('operation')
        
        if operation == 'insert':
            pos = edit.get('position')
            text = edit.get('text')
            self.documents[doc_id] = (
                self.documents[doc_id][:pos] + 
                text + 
                self.documents[doc_id][pos:]
            )
    
    def _get_cursors(self, doc_id: str) -> Dict:
        return {
            k.split(':')[1]: v 
            for k, v in self.cursors.items() 
            if k.startswith(f"{doc_id}:")
        }

Resources

Conclusion

WebSockets enable the real-time, bidirectional communication that modern applications require. From chat applications to collaborative editing, from live dashboards to IoT devices, WebSockets provide the foundation for interactive, responsive user experiences.

This guide covered the WebSocket protocol in depth, server and client implementations in multiple languages, security considerations, scaling patterns, and practical use cases. With this knowledge, you can build robust, scalable real-time applications that push data to users instantly when it matters most.

Comments

👍 Was this article helpful?