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.