Toolmingo
Guides10 min read

WebSockets Explained: A Complete Guide with Examples

Learn how WebSockets work, how to use the browser WebSocket API, and how to build real-time apps with Node.js, Python, Go, and PHP. Includes reconnection logic, security, and common pitfalls.

WebSockets let a browser and server talk to each other at any time — the server can push data without waiting for the client to ask. This guide covers how the protocol works, how to use it in every major language, and the patterns you need for production.

Quick reference

Task Code
Open connection (browser) const ws = new WebSocket('wss://example.com/ws')
Send string ws.send('hello')
Send JSON ws.send(JSON.stringify({ type: 'msg', text: 'hi' }))
Receive message ws.onmessage = e => console.log(e.data)
Check state ws.readyState === WebSocket.OPEN
Close gracefully ws.close(1000, 'done')
Node.js server npm install ws
Python server pip install websockets
Go server go get github.com/gorilla/websocket
Secure protocol wss:// (TLS, like HTTPS)
HTTP upgrade header Upgrade: websocket
Reconnect pattern exponential backoff in onclose handler

How WebSockets work

WebSocket starts as a regular HTTP request — the client sends an Upgrade header, and if the server agrees, the connection switches protocols:

Client → Server:
GET /ws HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13

Server → Client:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

After the handshake, the TCP connection stays open. Either side can send frames at any time — no need to wait for a request.

Key properties:

  • Full-duplex: client and server send simultaneously
  • Low overhead: frames have 2–10 byte headers vs ~800 bytes for HTTP
  • Persistent: one connection, no polling loops
  • Built-in ping/pong: heartbeat frames keep connections alive

Browser WebSocket API

The browser WebSocket object handles everything — connection, events, framing.

// Connect
const ws = new WebSocket('wss://example.com/ws');

// Connection events
ws.onopen = () => {
  console.log('Connected');
  ws.send(JSON.stringify({ type: 'join', room: 'general' }));
};

ws.onmessage = (event) => {
  const data = JSON.parse(event.data);
  console.log('Received:', data);
};

ws.onerror = (error) => {
  console.error('WebSocket error:', error);
};

ws.onclose = (event) => {
  console.log(`Closed: code=${event.code} reason=${event.reason}`);
};

// Send data
ws.send('plain string');                        // text frame
ws.send(JSON.stringify({ type: 'ping' }));      // JSON
ws.send(new Uint8Array([1, 2, 3]));            // binary frame

// Check readyState before sending
if (ws.readyState === WebSocket.OPEN) {
  ws.send('safe send');
}

// Close with code and reason
ws.close(1000, 'User logged out');

readyState values

Value Constant Meaning
0 CONNECTING Handshake in progress
1 OPEN Connection established, can send
2 CLOSING Close handshake in progress
3 CLOSED Connection closed

Reconnection with exponential backoff

Connections drop. Always reconnect automatically:

class ReconnectingWebSocket {
  constructor(url) {
    this.url = url;
    this.delay = 1000;
    this.maxDelay = 30000;
    this.connect();
  }

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

    this.ws.onopen = () => {
      console.log('Connected');
      this.delay = 1000; // reset backoff
      this.onopen?.();
    };

    this.ws.onmessage = (e) => this.onmessage?.(e);

    this.ws.onclose = (e) => {
      if (e.code !== 1000) { // 1000 = normal closure
        console.log(`Reconnecting in ${this.delay}ms`);
        setTimeout(() => this.connect(), this.delay);
        this.delay = Math.min(this.delay * 2, this.maxDelay);
      }
    };
  }

  send(data) {
    if (this.ws.readyState === WebSocket.OPEN) {
      this.ws.send(typeof data === 'string' ? data : JSON.stringify(data));
    }
  }

  close() {
    this.ws.close(1000, 'Deliberate close');
  }
}

// Usage
const ws = new ReconnectingWebSocket('wss://example.com/ws');
ws.onopen = () => ws.send({ type: 'hello' });
ws.onmessage = (e) => console.log(JSON.parse(e.data));

Server implementations

Node.js (ws library)

import { WebSocketServer } from 'ws';
import { createServer } from 'http';

const server = createServer();
const wss = new WebSocketServer({ server });

// Track connected clients
const clients = new Set();

wss.on('connection', (ws, req) => {
  const ip = req.socket.remoteAddress;
  console.log(`Client connected: ${ip}`);
  clients.add(ws);

  // Send welcome message
  ws.send(JSON.stringify({ type: 'welcome', clients: clients.size }));

  ws.on('message', (raw) => {
    try {
      const msg = JSON.parse(raw.toString());
      handleMessage(ws, msg);
    } catch {
      ws.send(JSON.stringify({ type: 'error', message: 'Invalid JSON' }));
    }
  });

  ws.on('close', () => {
    clients.delete(ws);
    console.log('Client disconnected');
  });

  ws.on('error', (err) => console.error('WS error:', err));
});

function handleMessage(ws, msg) {
  if (msg.type === 'broadcast') {
    // Send to all connected clients
    for (const client of clients) {
      if (client.readyState === 1 /* OPEN */) {
        client.send(JSON.stringify({ type: 'message', text: msg.text }));
      }
    }
  }
}

// Heartbeat: detect dead connections
setInterval(() => {
  for (const ws of clients) {
    if (!ws.isAlive) { ws.terminate(); clients.delete(ws); return; }
    ws.isAlive = false;
    ws.ping();
  }
}, 30000);

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

server.listen(8080, () => console.log('WS server on :8080'));

Python (websockets library)

import asyncio
import json
import websockets
from websockets.server import WebSocketServerProtocol

connected: set[WebSocketServerProtocol] = set()

async def handler(websocket: WebSocketServerProtocol) -> None:
    connected.add(websocket)
    try:
        await websocket.send(json.dumps({"type": "welcome"}))

        async for raw in websocket:
            try:
                msg = json.loads(raw)
            except json.JSONDecodeError:
                await websocket.send(json.dumps({"type": "error", "message": "Invalid JSON"}))
                continue

            if msg.get("type") == "broadcast":
                payload = json.dumps({"type": "message", "text": msg.get("text", "")})
                # Send to all except sender
                others = connected - {websocket}
                websockets.broadcast(others, payload)

    except websockets.exceptions.ConnectionClosed:
        pass
    finally:
        connected.discard(websocket)

async def main() -> None:
    async with websockets.serve(handler, "localhost", 8080):
        print("WebSocket server on ws://localhost:8080")
        await asyncio.Future()  # run forever

asyncio.run(main())

Go (gorilla/websocket)

package main

import (
    "encoding/json"
    "log"
    "net/http"
    "sync"

    "github.com/gorilla/websocket"
)

var upgrader = websocket.Upgrader{
    CheckOrigin: func(r *http.Request) bool {
        return true // validate origin in production
    },
}

type Hub struct {
    mu      sync.Mutex
    clients map[*websocket.Conn]bool
}

func (h *Hub) add(c *websocket.Conn) {
    h.mu.Lock()
    h.clients[c] = true
    h.mu.Unlock()
}

func (h *Hub) remove(c *websocket.Conn) {
    h.mu.Lock()
    delete(h.clients, c)
    h.mu.Unlock()
}

func (h *Hub) broadcast(msg []byte) {
    h.mu.Lock()
    defer h.mu.Unlock()
    for c := range h.clients {
        _ = c.WriteMessage(websocket.TextMessage, msg)
    }
}

var hub = &Hub{clients: make(map[*websocket.Conn]bool)}

func wsHandler(w http.ResponseWriter, r *http.Request) {
    conn, err := upgrader.Upgrade(w, r, nil)
    if err != nil {
        log.Println("upgrade:", err)
        return
    }
    defer conn.Close()
    hub.add(conn)
    defer hub.remove(conn)

    welcome, _ := json.Marshal(map[string]string{"type": "welcome"})
    _ = conn.WriteMessage(websocket.TextMessage, welcome)

    for {
        _, raw, err := conn.ReadMessage()
        if err != nil {
            break
        }
        var msg map[string]string
        if json.Unmarshal(raw, &msg) == nil && msg["type"] == "broadcast" {
            payload, _ := json.Marshal(map[string]string{
                "type": "message", "text": msg["text"],
            })
            hub.broadcast(payload)
        }
    }
}

func main() {
    http.HandleFunc("/ws", wsHandler)
    log.Fatal(http.ListenAndServe(":8080", nil))
}

PHP (Ratchet)

// composer require cboden/ratchet
require __DIR__ . '/vendor/autoload.php';

use Ratchet\MessageComponentInterface;
use Ratchet\ConnectionInterface;
use Ratchet\Server\IoServer;
use Ratchet\Http\HttpServer;
use Ratchet\WebSocket\WsServer;

class ChatServer implements MessageComponentInterface {
    private \SplObjectStorage $clients;

    public function __construct() {
        $this->clients = new \SplObjectStorage();
    }

    public function onOpen(ConnectionInterface $conn): void {
        $this->clients->attach($conn);
        $conn->send(json_encode(['type' => 'welcome']));
    }

    public function onMessage(ConnectionInterface $from, $raw): void {
        $msg = json_decode($raw, true);
        if ($msg['type'] === 'broadcast') {
            $payload = json_encode(['type' => 'message', 'text' => $msg['text']]);
            foreach ($this->clients as $client) {
                if ($client !== $from) {
                    $client->send($payload);
                }
            }
        }
    }

    public function onClose(ConnectionInterface $conn): void {
        $this->clients->detach($conn);
    }

    public function onError(ConnectionInterface $conn, \Exception $e): void {
        $conn->close();
    }
}

$server = IoServer::factory(
    new HttpServer(new WsServer(new ChatServer())),
    8080
);
$server->run();

Real-world patterns

Message envelope format

Define a consistent message structure so handlers stay clean:

// All messages follow: { type, payload, id? }
const MSG = {
  join:      (room) => ({ type: 'join', payload: { room } }),
  chat:      (text) => ({ type: 'chat', payload: { text } }),
  presence:  (users) => ({ type: 'presence', payload: { users } }),
  error:     (msg)  => ({ type: 'error', payload: { message: msg } }),
};

// Dispatcher on the server
function dispatch(ws, msg) {
  const handlers = {
    join:  handleJoin,
    chat:  handleChat,
    ping:  () => ws.send(JSON.stringify({ type: 'pong' })),
  };
  (handlers[msg.type] ?? handleUnknown)(ws, msg.payload);
}

Rooms / channels

// Server-side room management (Node.js)
const rooms = new Map(); // roomId → Set<WebSocket>

function joinRoom(ws, roomId) {
  if (!rooms.has(roomId)) rooms.set(roomId, new Set());
  rooms.get(roomId).add(ws);
}

function leaveRoom(ws, roomId) {
  rooms.get(roomId)?.delete(ws);
}

function broadcast(roomId, msg, exclude = null) {
  for (const client of rooms.get(roomId) ?? []) {
    if (client !== exclude && client.readyState === 1) {
      client.send(JSON.stringify(msg));
    }
  }
}

React hook (useWebSocket)

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

export function useWebSocket(url: string) {
  const wsRef = useRef<WebSocket | null>(null);

  const connect = useCallback(() => {
    const ws = new WebSocket(url);
    ws.onopen    = () => console.log('WS connected');
    ws.onclose   = () => setTimeout(connect, 2000); // reconnect
    ws.onerror   = (e) => console.error('WS error', e);
    wsRef.current = ws;
  }, [url]);

  useEffect(() => {
    connect();
    return () => wsRef.current?.close(1000, 'component unmount');
  }, [connect]);

  const send = useCallback((data: unknown) => {
    if (wsRef.current?.readyState === WebSocket.OPEN) {
      wsRef.current.send(JSON.stringify(data));
    }
  }, []);

  return { send };
}

Security

Use wss:// (TLS)

Plain ws:// sends data unencrypted. Always use wss:// in production — it's WebSocket over TLS, the same as HTTPS.

Validate the Origin header

// Node.js: reject connections from unexpected origins
const wss = new WebSocketServer({
  server,
  verifyClient: ({ origin }) => {
    const allowed = ['https://myapp.com', 'https://www.myapp.com'];
    return allowed.includes(origin);
  },
});

Authenticate before upgrading

// Attach a token to the URL or first message
// Option A: query param (visible in logs — use short-lived tokens)
const ws = new WebSocket('wss://example.com/ws?token=eyJ...');

// Option B: authenticate on first message
ws.onopen = () => ws.send(JSON.stringify({ type: 'auth', token: getJWT() }));

// Server: close connection if auth fails within 5 seconds
ws.on('connection', (ws) => {
  let authenticated = false;
  const timer = setTimeout(() => {
    if (!authenticated) ws.close(1008, 'Auth timeout');
  }, 5000);

  ws.on('message', (raw) => {
    const msg = JSON.parse(raw);
    if (msg.type === 'auth') {
      authenticated = verifyJWT(msg.token);
      clearTimeout(timer);
      if (!authenticated) ws.close(1008, 'Unauthorized');
    }
  });
});

Rate limiting

const RATE_LIMIT = 10; // messages per second
const counts = new Map();

ws.on('message', (raw) => {
  const now = Date.now();
  const { count, resetAt } = counts.get(ws) ?? { count: 0, resetAt: now + 1000 };
  if (now > resetAt) {
    counts.set(ws, { count: 1, resetAt: now + 1000 });
  } else if (count >= RATE_LIMIT) {
    ws.close(1008, 'Rate limit exceeded');
    return;
  } else {
    counts.set(ws, { count: count + 1, resetAt });
  }
  // process message...
});

WebSocket vs alternatives

WebSocket HTTP polling Server-Sent Events HTTP/2 push
Direction Bidirectional Client → Server Server → Client only Server → Client only
Latency Very low Poll interval Low Low
Overhead Very low High (headers per request) Low Low
Browser support All modern All All (no IE) All modern
Firewalls Sometimes blocked Never blocked Never blocked Never blocked
Use case Chat, games, collaboration Simple polling Live feed, notifications Asset push
Reconnect Manual Automatic Automatic N/A

Rule of thumb: use WebSockets when you need the server to push frequently and the client also sends data. Use SSE when the server pushes only. Use polling when simplicity matters more than latency.

Close codes

Code Meaning
1000 Normal closure
1001 Going away (page navigation)
1002 Protocol error
1003 Unsupported data type
1006 Abnormal closure (no close frame)
1007 Invalid data (e.g., non-UTF-8 text)
1008 Policy violation (auth, rate limit)
1009 Message too big
1011 Server error

Common mistakes

Mistake Problem Fix
Not checking readyState before send() Exception or silent drop Check ws.readyState === WebSocket.OPEN
No reconnect logic App breaks on any drop Implement exponential backoff in onclose
Using ws:// in production Unencrypted traffic, MITM attacks Always use wss://
No origin validation Cross-site WebSocket hijacking Check Origin header in verifyClient
No authentication Anyone can connect Auth on upgrade or first message
No heartbeat/ping-pong Dead connections accumulate Send ping every 30s, terminate on no pong
Storing ws objects in React state Unnecessary re-renders, stale closures Use useRef for the WebSocket instance

FAQ

Is WebSocket supported everywhere?
All modern browsers support it. For very old IE you'd need a Socket.IO fallback, but that's rarely needed today.

Can WebSockets go through proxies and firewalls?
Sometimes not — some corporate proxies block non-HTTP traffic. If reliability behind restrictive networks matters, consider falling back to long-polling (Socket.IO handles this automatically).

What is Socket.IO and do I need it?
Socket.IO is a library on top of WebSockets that adds reconnection, rooms, namespaces, and fallbacks. You don't need it — the patterns in this guide cover the same features with the native API.

How many concurrent WebSocket connections can a server handle?
Each connection holds a file descriptor and some memory (~5–10 KB). A single Node.js process can handle tens of thousands of connections. For very high scale, use a pub/sub broker (Redis, NATS) to distribute messages across multiple processes.

Can I use WebSockets with Next.js or Vercel?
Serverless platforms don't support persistent connections. You need a separate WebSocket server (Node.js process) or a managed service like Ably, Pusher, or Supabase Realtime.

When should I use Server-Sent Events instead?
If your app only needs server-to-client push (live scores, notifications, log streaming) and the client never sends data, SSE is simpler — it works over plain HTTP with no special server setup, reconnects automatically, and isn't blocked by proxies.

Related tools

Keep reading

All Toolmingotools are free & run in your browser

No sign-up, no upload, no watermark. Your files never leave your device.

Browse all tools