HTTP is request-response: you ask, the server answers, connection closes. That works for loading pages. It breaks down for real-time apps — chat, live dashboards, multiplayer games — where the server needs to push data without waiting for a request.
WebSockets solve this with a persistent, full-duplex connection. Client and server can both send messages at any time, over a single TCP connection.
This guide covers how WebSockets work, how to implement them in JavaScript, Python, Go, and PHP, and when to reach for alternatives.
How WebSockets Work
WebSocket starts as an HTTP request and then upgrades to a persistent connection.
1. Client sends HTTP Upgrade request:
GET /chat HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
2. Server responds:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
3. Both sides can now send frames at any time.
Connection stays open until either side closes it.
After the handshake, the connection is no longer HTTP — it's a WebSocket connection over the same TCP socket.
Key properties:
- Full-duplex: client and server send independently
- Low overhead: no HTTP headers on every message (just a small frame header)
- Persistent: no reconnect overhead
- Supports text and binary frames
Quick Reference
| Operation | Browser JS | Node.js (ws) | Python (websockets) | Go (gorilla/websocket) |
|---|---|---|---|---|
| Connect / listen | new WebSocket(url) |
new WebSocketServer({port}) |
websockets.serve(handler, host, port) |
upgrader.Upgrade(w, r, nil) |
| Send text | ws.send(str) |
ws.send(str) |
await ws.send(str) |
conn.WriteMessage(TextMessage, data) |
| Send JSON | ws.send(JSON.stringify(obj)) |
same | await ws.send(json.dumps(obj)) |
conn.WriteJSON(obj) |
| Receive | ws.onmessage = e => ... |
ws.on('message', fn) |
msg = await ws.recv() |
conn.ReadMessage() |
| Close | ws.close() |
ws.close() |
await ws.close() |
conn.Close() |
| URL scheme | ws:// or wss:// (TLS) |
same | same | same |
Browser WebSocket API
The browser exposes a simple WebSocket class.
// Connect
const ws = new WebSocket('wss://example.com/ws');
// Connection opened
ws.addEventListener('open', () => {
console.log('Connected');
ws.send('Hello server!');
ws.send(JSON.stringify({ type: 'join', room: 'general' }));
});
// Receive message
ws.addEventListener('message', (event) => {
const data = JSON.parse(event.data);
console.log('Received:', data);
});
// Connection closed
ws.addEventListener('close', (event) => {
console.log(`Closed: code=${event.code}, reason=${event.reason}`);
// Reconnect after 3 seconds
setTimeout(connect, 3000);
});
// Error
ws.addEventListener('error', (err) => {
console.error('WebSocket error:', err);
// 'close' will fire right after
});
// Send helpers
function sendJson(obj) {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(obj));
}
}
// Close gracefully
function disconnect() {
ws.close(1000, 'User logged out');
}
WebSocket ready states:
readyState |
Constant | Meaning |
|---|---|---|
0 |
CONNECTING |
Handshake in progress |
1 |
OPEN |
Connected, can send/receive |
2 |
CLOSING |
Close handshake started |
3 |
CLOSED |
Connection closed |
Check ws.readyState === WebSocket.OPEN before calling .send().
Node.js WebSocket Server (ws library)
// npm install ws
import { WebSocketServer } from 'ws';
const wss = new WebSocketServer({ port: 8080 });
// Track connected clients
const clients = new Set();
wss.on('connection', (ws, req) => {
clients.add(ws);
const ip = req.socket.remoteAddress;
console.log(`Client connected from ${ip}. Total: ${clients.size}`);
// Receive message
ws.on('message', (rawData) => {
const msg = JSON.parse(rawData.toString());
console.log('Received:', msg);
// Broadcast to all other clients
for (const client of clients) {
if (client !== ws && client.readyState === 1 /* OPEN */) {
client.send(JSON.stringify({ ...msg, from: ip }));
}
}
});
// Client disconnected
ws.on('close', (code, reason) => {
clients.delete(ws);
console.log(`Client disconnected. code=${code}. Total: ${clients.size}`);
});
ws.on('error', (err) => {
console.error('Client error:', err.message);
});
// Send welcome message
ws.send(JSON.stringify({ type: 'welcome', clients: clients.size }));
});
console.log('WebSocket server running on ws://localhost:8080');
With Express (HTTP + WS on same port):
import express from 'express';
import { createServer } from 'http';
import { WebSocketServer } from 'ws';
const app = express();
const server = createServer(app);
const wss = new WebSocketServer({ server });
app.get('/', (req, res) => res.send('Hello HTTP'));
wss.on('connection', (ws) => {
ws.send(JSON.stringify({ type: 'connected' }));
});
server.listen(3000, () => console.log('Server on http://localhost:3000'));
Python WebSocket Server (websockets library)
# pip install websockets
import asyncio
import json
import websockets
# Track clients
connected: set[websockets.WebSocketServerProtocol] = set()
async def handler(websocket: websockets.WebSocketServerProtocol):
connected.add(websocket)
try:
async for raw in websocket:
msg = json.loads(raw)
print(f"Received: {msg}")
# Broadcast to all other clients
broadcast = json.dumps({**msg, "from": websocket.remote_address[0]})
tasks = [
client.send(broadcast)
for client in connected
if client is not websocket
]
if tasks:
await asyncio.gather(*tasks)
except websockets.exceptions.ConnectionClosedError:
pass
finally:
connected.discard(websocket)
async def main():
async with websockets.serve(handler, "0.0.0.0", 8765):
print("WebSocket server running on ws://localhost:8765")
await asyncio.Future() # run forever
asyncio.run(main())
Python WebSocket client:
import asyncio
import json
import websockets
async def client():
async with websockets.connect("ws://localhost:8765") as ws:
await ws.send(json.dumps({"type": "hello", "text": "Hi!"}))
async for raw in ws:
msg = json.loads(raw)
print("Server says:", msg)
asyncio.run(client())
Go WebSocket Server (gorilla/websocket)
// go get github.com/gorilla/websocket
package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
"sync"
"github.com/gorilla/websocket"
)
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true // allow all origins in dev; restrict in production
},
}
type Hub struct {
mu sync.Mutex
clients map[*websocket.Conn]bool
}
func (h *Hub) add(c *websocket.Conn) {
h.mu.Lock(); defer h.mu.Unlock()
h.clients[c] = true
}
func (h *Hub) remove(c *websocket.Conn) {
h.mu.Lock(); defer h.mu.Unlock()
delete(h.clients, c)
c.Close()
}
func (h *Hub) broadcast(msg []byte, skip *websocket.Conn) {
h.mu.Lock(); defer h.mu.Unlock()
for c := range h.clients {
if c != skip {
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 error:", err)
return
}
hub.add(conn)
defer hub.remove(conn)
for {
_, rawMsg, err := conn.ReadMessage()
if err != nil {
break // client disconnected
}
var msg map[string]any
if err := json.Unmarshal(rawMsg, &msg); err == nil {
fmt.Println("Received:", msg)
hub.broadcast(rawMsg, conn)
}
}
}
func main() {
http.HandleFunc("/ws", wsHandler)
log.Println("Listening on :8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}
PHP WebSocket (Ratchet)
// composer require cboden/ratchet
use Ratchet\MessageComponentInterface;
use Ratchet\ConnectionInterface;
use Ratchet\Server\IoServer;
use Ratchet\Http\HttpServer;
use Ratchet\WebSocket\WsServer;
class ChatServer implements MessageComponentInterface {
protected \SplObjectStorage $clients;
public function __construct() {
$this->clients = new \SplObjectStorage;
}
public function onOpen(ConnectionInterface $conn) {
$this->clients->attach($conn);
echo "New connection #{$conn->resourceId}\n";
}
public function onMessage(ConnectionInterface $from, $msg) {
$data = json_decode($msg, true);
echo "Received from #{$from->resourceId}: " . print_r($data, true);
// Broadcast to all other clients
foreach ($this->clients as $client) {
if ($client !== $from) {
$client->send($msg);
}
}
}
public function onClose(ConnectionInterface $conn) {
$this->clients->detach($conn);
echo "Connection #{$conn->resourceId} closed\n";
}
public function onError(ConnectionInterface $conn, \Exception $e) {
echo "Error: {$e->getMessage()}\n";
$conn->close();
}
}
$server = IoServer::factory(
new HttpServer(new WsServer(new ChatServer())),
8080
);
$server->run();
Message Protocol Pattern
Raw WebSocket sends strings or bytes — you design the protocol. A common pattern uses typed JSON messages:
// Message envelope
const Message = {
// Client → server
join: (room) => ({ type: 'join', room }),
leave: (room) => ({ type: 'leave', room }),
chat: (room, text) => ({ type: 'chat', room, text }),
ping: () => ({ type: 'ping' }),
// Server → client
joined: (room, members) => ({ type: 'joined', room, members }),
chatted: (room, from, text, ts) => ({ type: 'chatted', room, from, text, ts }),
error: (code, msg) => ({ type: 'error', code, message: msg }),
pong: () => ({ type: 'pong' }),
};
// Dispatcher on the client
ws.addEventListener('message', ({ data }) => {
const msg = JSON.parse(data);
switch (msg.type) {
case 'joined': onJoined(msg.room, msg.members); break;
case 'chatted': onChatted(msg); break;
case 'error': onError(msg.code, msg.message); break;
case 'pong': lastPong = Date.now(); break;
default: console.warn('Unknown message type:', msg.type);
}
});
Auto-Reconnect Pattern
WebSocket connections drop. Always reconnect with exponential backoff:
class ReconnectingWebSocket {
#ws = null;
#attempt = 0;
#maxDelay = 30_000;
#handlers = {};
constructor(url) {
this.url = url;
this.#connect();
}
#connect() {
this.#ws = new WebSocket(this.url);
this.#ws.addEventListener('open', () => {
this.#attempt = 0; // reset backoff on success
this.#handlers.open?.();
});
this.#ws.addEventListener('message', (e) => {
this.#handlers.message?.(e);
});
this.#ws.addEventListener('close', (e) => {
this.#handlers.close?.(e);
if (!this.#closed) {
const delay = Math.min(1000 * 2 ** this.#attempt, this.#maxDelay);
this.#attempt++;
console.log(`Reconnecting in ${delay}ms (attempt ${this.#attempt})`);
setTimeout(() => this.#connect(), delay);
}
});
}
send(data) {
if (this.#ws?.readyState === WebSocket.OPEN) {
this.#ws.send(typeof data === 'string' ? data : JSON.stringify(data));
}
}
on(event, handler) { this.#handlers[event] = handler; }
close() {
this.#closed = true;
this.#ws?.close(1000, 'Client closed');
}
}
// Usage
const ws = new ReconnectingWebSocket('wss://api.example.com/ws');
ws.on('open', () => ws.send({ type: 'auth', token: getToken() }));
ws.on('message', (e) => dispatch(JSON.parse(e.data)));
Heartbeat / Ping-Pong
The WebSocket protocol has built-in ping/pong frames. The ws library on Node.js handles them automatically. For application-level heartbeats:
// Node.js server — kill dead clients
const INTERVAL = 30_000;
wss.on('connection', (ws) => {
ws.isAlive = true;
ws.on('pong', () => { ws.isAlive = true; });
});
setInterval(() => {
for (const ws of wss.clients) {
if (!ws.isAlive) { ws.terminate(); continue; }
ws.isAlive = false;
ws.ping();
}
}, INTERVAL);
// Browser client — send app-level ping
setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'ping' }));
}
}, 25_000);
WebSocket vs Alternatives
| Technology | Direction | Protocol | Use Case |
|---|---|---|---|
| WebSocket | Full-duplex | TCP (ws/wss) | Chat, games, live collaboration |
| Server-Sent Events (SSE) | Server → client only | HTTP | Live feeds, dashboards, notifications |
| Long Polling | Server → client (simulated) | HTTP | Fallback for environments without WS |
| HTTP/2 Push | Server → client | HTTP/2 | Pushed resources (CSS, JS) |
| gRPC streaming | Both directions | HTTP/2 + protobuf | Microservices, high-throughput |
Use SSE when: you only need server-push (activity feeds, build logs). SSE is simpler, works over plain HTTP, and auto-reconnects natively.
Use WebSocket when: you need bidirectional communication (client sends too), real-time two-way interaction, or binary data.
Security
// 1. Always use wss:// in production (TLS)
const ws = new WebSocket('wss://api.example.com/ws'); // ✓
// not ws:// over the internet
// 2. Authenticate on connection (token in query string or first message)
// Query string — visible in server logs, use with caution:
const ws = new WebSocket(`wss://api.example.com/ws?token=${token}`);
// First message (preferred — not logged):
ws.addEventListener('open', () => {
ws.send(JSON.stringify({ type: 'auth', token: getJwtToken() }));
});
// 3. Server must validate origin (gorilla/websocket CheckOrigin, ws verifyClient)
const wss = new WebSocketServer({
port: 8080,
verifyClient: ({ req }) => {
const origin = req.headers.origin;
return ['https://myapp.com'].includes(origin);
},
});
// 4. Validate and sanitize all incoming messages — never trust client data
ws.on('message', (raw) => {
let msg;
try { msg = JSON.parse(raw.toString()); }
catch { ws.close(1003, 'Invalid JSON'); return; }
if (typeof msg.type !== 'string') return; // drop unknown structure
// ... further validation
});
// 5. Rate-limit messages per client to prevent flooding
6 Common Mistakes
| Mistake | What Goes Wrong | Fix |
|---|---|---|
Sending before open event |
ws.send() throws or silently fails |
Check ws.readyState === WebSocket.OPEN |
| No reconnect logic | Connection drops and app breaks silently | Implement exponential backoff on close |
| No heartbeat | Idle connections silently time out (NAT/proxy) | Send ping every 25–30 seconds |
Using ws:// in production |
Traffic is unencrypted | Always use wss:// |
| Skipping authentication | Any client can connect | Validate token on first message or upgrade |
| Broadcasting without room logic | All clients receive all messages | Track rooms/channels; filter recipients |
FAQ
Can I use WebSockets with HTTP/2?
Browser WebSockets run over HTTP/1.1 upgrade. There's a newer RFC for WebSockets over HTTP/2 (RFC 8441), but browser support is limited. For HTTP/2 push patterns, consider SSE.
How many WebSocket connections can a server handle?
Each connection is a file descriptor. Linux default is 1,024; tune with ulimit -n and kernel settings. Node.js can handle tens of thousands of idle connections with proper setup. For massive scale, use a message broker (Redis Pub/Sub, NATS) to coordinate across multiple server instances.
Do WebSockets work through proxies and firewalls?
Most modern proxies handle wss:// (TLS port 443) correctly. Plain ws:// on port 80 is sometimes blocked by corporate proxies that don't understand the upgrade. Use wss:// on port 443 for maximum compatibility.
How do I scale WebSockets horizontally?
WebSocket state (which client is connected to which server) is local. When you scale to multiple processes/servers, use a pub/sub layer (Redis Pub/Sub, Socket.IO adapter) so any server can broadcast to clients on any other server.
// Redis-backed Socket.IO for horizontal scaling
import { createAdapter } from '@socket.io/redis-adapter';
import { createClient } from 'redis';
const pubClient = createClient({ url: 'redis://localhost:6379' });
const subClient = pubClient.duplicate();
await Promise.all([pubClient.connect(), subClient.connect()]);
io.adapter(createAdapter(pubClient, subClient));
What's the difference between Socket.IO and raw WebSocket?
Socket.IO is a library built on top of WebSockets (with long-polling fallback). It adds rooms, namespaces, auto-reconnect, and acknowledgements. Raw WebSocket is lighter but you implement these yourself. Use Socket.IO for quick prototypes and complex pub/sub needs; use raw WebSocket when you need minimal overhead or control.
How do I debug WebSocket traffic?
In Chrome DevTools → Network tab → filter by "WS" → click the connection → Messages tab. You see each frame sent and received. For server-side, log all incoming messages with timestamps during development.