Full-duplex real-time communication over a single TCP connection
The essential API and setup commands at a glance. Copy-paste these to get a WebSocket connection running in seconds.
// Connect
const ws = new WebSocket('wss://example.com/ws');
// Send
ws.send('Hello server');
ws.send(JSON.stringify({ type: 'chat', msg: 'hi' }));
// Receive
ws.onmessage = (e) => console.log(e.data);
// Close
ws.close(1000, 'Normal closure');
npm install ws
import { WebSocketServer } from 'ws';
const wss = new WebSocketServer({ port: 8080 });
wss.on('connection', (ws) => {
ws.on('message', (data) => {
console.log('received: %s', data);
ws.send('echo: ' + data);
});
ws.send('Connected to server');
});
pip install websockets
import asyncio
import websockets
async def handler(websocket):
async for message in websocket:
await websocket.send(f"echo: {message}")
async def main():
async with websockets.serve(handler, "localhost", 8080):
await asyncio.Future() # run forever
asyncio.run(main())
npx wscat -c wss://echo.websocket.org
Interactive WebSocket client for testing
websocat wss://echo.websocket.org
Swiss-army tool for WebSocket pipes
curl --include \
--header "Upgrade: websocket" \
--header "Connection: Upgrade" \
--header "Sec-WebSocket-Key: x3JJHMbD..." \
--header "Sec-WebSocket-Version: 13" \
http://localhost:8080/ws
Manual HTTP upgrade inspection
F12 > Network > WS filter
Inspect frames, timing, close codes
WebSocket (RFC 6455) starts as an HTTP/1.1 upgrade, then switches to a binary framing protocol over the same TCP connection.
The connection begins with a standard HTTP request containing upgrade headers. The server responds with 101 Switching Protocols.
// Client Request
GET /ws HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Sec-WebSocket-Protocol: chat, superchat
Origin: https://example.com
// Server Response
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Sec-WebSocket-Protocol: chat
Sec-WebSocket-Accept value is a SHA-1 hash of the client key concatenated with the magic GUID 258EAFA5-E914-47DA-95CA-C5AB0DC85B11, then base64-encoded. This prevents caching proxies from replaying old responses.After the handshake, data flows as frames. Each frame has a compact binary header:
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-------+-+-------------+-------------------------------+
|F|R|R|R| opcode|M| Payload len | Extended payload length |
|I|S|S|S| (4) |A| (7) | (16/64 bits) |
|N|V|V|V| |S| | (if payload len == 126/127) |
| |1|2|3| |K| | |
+-+-+-+-+-------+-+-------------+-------------------------------+
| Masking-key (0 or 4 bytes) | |
+-------------------------------+-------------------------------+
| Payload Data |
+---------------------------------------------------------------+
| Field | Bits | Description |
|---|---|---|
FIN | 1 | Final fragment in a message (1 = last) |
RSV1-3 | 3 | Reserved for extensions (e.g., permessage-deflate uses RSV1) |
Opcode | 4 | Frame type (see table below) |
MASK | 1 | Whether payload is masked (client-to-server MUST mask) |
Payload length | 7+ | 0-125 inline, 126 = next 2 bytes, 127 = next 8 bytes |
Masking key | 0/32 | XOR key for payload (only if MASK=1) |
| Opcode | Hex | Type | Description |
|---|---|---|---|
0x0 | 0 | Continuation | Fragment of a multi-frame message |
0x1 | 1 | Text | UTF-8 text frame |
0x2 | 2 | Binary | Binary data frame |
0x8 | 8 | Close | Connection close (may include status code + reason) |
0x9 | 9 | Ping | Heartbeat request (must respond with Pong) |
0xA | 10 | Pong | Heartbeat response (echoes ping payload) |
All frames from client to server MUST be masked. The 4-byte masking key is randomly generated per frame. The payload is XORed byte-by-byte:
// Masking algorithm (RFC 6455 Section 5.3)
maskedByte[i] = originalByte[i] XOR maskingKey[i % 4]
// Example: mask key = [0x37, 0xfa, 0x21, 0x3d]
// Payload: "Hello" = [0x48, 0x65, 0x6c, 0x6c, 0x6f]
// Masked: [0x48^0x37, 0x65^0xfa, 0x6c^0x21, 0x6c^0x3d, 0x6f^0x37]
// = [0x7f, 0x9f, 0x4d, 0x51, 0x58]
wss:// (TLS) for actual security.| Code | Name | Meaning |
|---|---|---|
1000 | Normal Closure | Clean shutdown |
1001 | Going Away | Server shutting down or browser navigating |
1002 | Protocol Error | Endpoint received a malformed frame |
1003 | Unsupported Data | Received data type it cannot handle |
1006 | Abnormal Closure | No close frame received (connection dropped) |
1007 | Invalid Payload | Data within a message is inconsistent |
1008 | Policy Violation | Generic policy violation |
1009 | Message Too Big | Message exceeds size limit |
1011 | Internal Error | Unexpected server condition |
1012 | Service Restart | Server is restarting |
1013 | Try Again Later | Temporary server condition |
1015 | TLS Handshake Fail | TLS handshake failure (never sent in close frame) |
4000-4999 | Private Use | Application-defined codes |
The browser's native WebSocket object. Zero dependencies, supported in all modern browsers since 2011.
// Basic connection
const ws = new WebSocket('wss://example.com/ws');
// With sub-protocol negotiation
const ws = new WebSocket('wss://example.com/ws', ['graphql-ws', 'chat']);
// The selected protocol is available after open
ws.onopen = () => console.log(ws.protocol); // "graphql-ws"
| Property | Type | Description |
|---|---|---|
ws.url | string | The URL passed to the constructor |
ws.readyState | number | 0=CONNECTING, 1=OPEN, 2=CLOSING, 3=CLOSED |
ws.protocol | string | Server-selected sub-protocol |
ws.bufferedAmount | number | Bytes queued but not yet sent |
ws.binaryType | string | "blob" (default) or "arraybuffer" |
ws.extensions | string | Negotiated extensions (e.g., "permessage-deflate") |
const ws = new WebSocket('wss://example.com/ws');
// Connection opened
ws.onopen = (event) => {
console.log('Connected');
ws.send('Hello!');
};
// Message received
ws.onmessage = (event) => {
// event.data is string for text frames,
// Blob or ArrayBuffer for binary frames
const data = JSON.parse(event.data);
console.log('Received:', data);
};
// Connection closed
ws.onclose = (event) => {
console.log(`Closed: code=${event.code} reason="${event.reason}"`);
console.log('Was clean:', event.wasClean);
};
// Error occurred (always followed by onclose)
ws.onerror = (event) => {
console.error('WebSocket error:', event);
// Note: error details are intentionally hidden
// for security (same-origin policy)
};
addEventListener for multiple handlers: ws.addEventListener('message', handler1)// String (text frame, opcode 0x1)
ws.send('Hello, server!');
// JSON (most common pattern)
ws.send(JSON.stringify({
type: 'chat',
room: 'general',
message: 'Hello everyone'
}));
// ArrayBuffer (binary frame, opcode 0x2)
const buffer = new ArrayBuffer(4);
new DataView(buffer).setUint32(0, 42);
ws.send(buffer);
// Blob (binary frame)
const blob = new Blob(['binary data'], { type: 'application/octet-stream' });
ws.send(blob);
// TypedArray (binary frame)
const bytes = new Uint8Array([0x01, 0x02, 0x03]);
ws.send(bytes);
// Check if send queue is backed up
if (ws.bufferedAmount === 0) {
ws.send(largePayload);
} else {
console.warn('Send buffer not empty, waiting...');
}
// Clean close
ws.close();
// Close with code and reason
ws.close(1000, 'User logged out');
// Close with application-specific code
ws.close(4001, 'Session expired');
// The close handshake is async; listen for onclose
ws.onclose = (e) => {
if (e.wasClean) {
console.log('Clean disconnect');
} else {
console.log('Connection lost (code:', e.code, ')');
}
};
Production-ready WebSocket servers across popular languages. Each handles the upgrade handshake, frame parsing, and connection management.
The most popular Node.js WebSocket library. Lightweight, fast, and spec-compliant.
import { WebSocketServer } from 'ws';
import { createServer } from 'http';
const server = createServer();
const wss = new WebSocketServer({ server });
wss.on('connection', (ws, req) => {
const ip = req.socket.remoteAddress;
console.log(`Client connected from ${ip}`);
ws.on('message', (data, isBinary) => {
// Broadcast to all other clients
wss.clients.forEach((client) => {
if (client !== ws && client.readyState === 1) {
client.send(data, { binary: isBinary });
}
});
});
ws.on('close', (code, reason) => {
console.log(`Disconnected: ${code} ${reason}`);
});
// Heartbeat with ping/pong
ws.isAlive = true;
ws.on('pong', () => { ws.isAlive = true; });
});
// Detect broken connections
const interval = setInterval(() => {
wss.clients.forEach((ws) => {
if (!ws.isAlive) return ws.terminate();
ws.isAlive = false;
ws.ping();
});
}, 30000);
wss.on('close', () => clearInterval(interval));
server.listen(8080);
Async-first Python library built on asyncio. Clean, modern API.
import asyncio
import websockets
import json
connected = set()
async def handler(websocket):
connected.add(websocket)
try:
async for message in websocket:
data = json.loads(message)
# Broadcast to all connected clients
websockets.broadcast(connected, json.dumps({
"user": data.get("user", "anonymous"),
"message": data["message"]
}))
finally:
connected.discard(websocket)
async def main():
async with websockets.serve(
handler,
"0.0.0.0",
8080,
ping_interval=20, # Send ping every 20s
ping_timeout=20, # Wait 20s for pong
max_size=2**20, # 1MB max message size
compression="deflate"
):
await asyncio.Future()
asyncio.run(main())
The standard Go WebSocket library. High performance, production-tested. Note: gorilla/websocket is in maintenance mode; nhooyr.io/websocket (or coder/websocket) is the modern alternative.
package main
import (
"log"
"net/http"
"github.com/gorilla/websocket"
)
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool {
// In production, validate origin
return true
},
}
func wsHandler(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Println("Upgrade error:", err)
return
}
defer conn.Close()
// Set read deadline for heartbeat
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
conn.SetPongHandler(func(string) error {
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
return nil
})
for {
msgType, msg, err := conn.ReadMessage()
if err != nil {
if websocket.IsUnexpectedCloseError(err,
websocket.CloseGoingAway,
websocket.CloseNormalClosure) {
log.Printf("Error: %v", err)
}
break
}
// Echo back
if err := conn.WriteMessage(msgType, msg); err != nil {
break
}
}
}
func main() {
http.HandleFunc("/ws", wsHandler)
log.Fatal(http.ListenAndServe(":8080", nil))
}
| Feature | ws (Node) | websockets (Python) | gorilla (Go) |
|---|---|---|---|
| Async model | Event loop | asyncio | Goroutines |
| Compression | permessage-deflate | permessage-deflate | Manual |
| Max message size | Configurable | Configurable | Configurable |
| Auto ping/pong | Manual | Built-in | Manual |
| HTTP integration | Native http | Standalone/ASGI | net/http |
| Connections per core | ~10K | ~5K | ~100K+ |
Connections drop. Networks fail. Servers restart. A robust WebSocket client must handle reconnection gracefully.
// WebSocket.readyState values
WebSocket.CONNECTING // 0 - Socket created, not yet open
WebSocket.OPEN // 1 - Connection established
WebSocket.CLOSING // 2 - close() called, handshake in progress
WebSocket.CLOSED // 3 - Connection closed or failed to open
// State flow:
// CONNECTING -> OPEN -> CLOSING -> CLOSED
// CONNECTING -> CLOSED (if connection fails)
class ReconnectingWebSocket {
constructor(url, options = {}) {
this.url = url;
this.maxRetries = options.maxRetries ?? Infinity;
this.baseDelay = options.baseDelay ?? 1000;
this.maxDelay = options.maxDelay ?? 30000;
this.retryCount = 0;
this.handlers = { message: [], open: [], close: [] };
this.connect();
}
connect() {
this.ws = new WebSocket(this.url);
this.ws.onopen = () => {
this.retryCount = 0; // Reset on success
this.handlers.open.forEach(fn => fn());
};
this.ws.onmessage = (e) => {
this.handlers.message.forEach(fn => fn(e));
};
this.ws.onclose = (e) => {
this.handlers.close.forEach(fn => fn(e));
if (!e.wasClean && this.retryCount < this.maxRetries) {
this.scheduleReconnect();
}
};
this.ws.onerror = () => {}; // onclose will fire after
}
scheduleReconnect() {
// Exponential backoff with jitter
const delay = Math.min(
this.baseDelay * Math.pow(2, this.retryCount)
+ Math.random() * 1000,
this.maxDelay
);
console.log(`Reconnecting in ${Math.round(delay)}ms...`);
this.retryCount++;
setTimeout(() => this.connect(), delay);
}
send(data) {
if (this.ws.readyState === WebSocket.OPEN) {
this.ws.send(data);
}
}
on(event, fn) { this.handlers[event].push(fn); }
close() {
this.maxRetries = 0; // Prevent reconnection
this.ws.close(1000, 'Client closing');
}
}
// Usage
const ws = new ReconnectingWebSocket('wss://api.example.com/ws', {
maxRetries: 10,
baseDelay: 1000,
maxDelay: 30000
});
// Client-side heartbeat
class HeartbeatWebSocket {
constructor(url) {
this.url = url;
this.heartbeatInterval = 25000; // 25s
this.heartbeatTimeout = 35000; // 35s to detect dead
this.connect();
}
connect() {
this.ws = new WebSocket(this.url);
this.ws.onopen = () => {
this.startHeartbeat();
};
this.ws.onmessage = (e) => {
this.resetTimeout(); // Any message resets the timer
if (e.data === 'pong') return; // Heartbeat response
// Handle actual messages...
};
this.ws.onclose = () => {
this.stopHeartbeat();
};
}
startHeartbeat() {
this.pingTimer = setInterval(() => {
if (this.ws.readyState === WebSocket.OPEN) {
this.ws.send('ping');
}
}, this.heartbeatInterval);
this.resetTimeout();
}
resetTimeout() {
clearTimeout(this.deadTimer);
this.deadTimer = setTimeout(() => {
console.warn('Connection appears dead, closing');
this.ws.close(4000, 'Heartbeat timeout');
}, this.heartbeatTimeout);
}
stopHeartbeat() {
clearInterval(this.pingTimer);
clearTimeout(this.deadTimer);
}
}
class QueuedWebSocket extends ReconnectingWebSocket {
constructor(url, options) {
super(url, options);
this.queue = [];
}
send(data) {
if (this.ws.readyState === WebSocket.OPEN) {
this.ws.send(data);
} else {
this.queue.push(data); // Buffer while disconnected
}
}
connect() {
super.connect();
const origOnOpen = this.ws.onopen;
this.ws.onopen = (e) => {
origOnOpen?.(e);
// Flush queued messages
while (this.queue.length > 0) {
this.ws.send(this.queue.shift());
}
};
}
}
WebSockets natively support binary frames alongside text. Use binary for game state, audio/video, file uploads, and custom protocols.
const ws = new WebSocket('wss://example.com/ws');
// Option 1: Receive as ArrayBuffer (recommended for parsing)
ws.binaryType = 'arraybuffer';
// Option 2: Receive as Blob (default, good for file-like data)
ws.binaryType = 'blob';
// ArrayBuffer
const buffer = new ArrayBuffer(8);
const view = new DataView(buffer);
view.setUint8(0, 0x01); // Message type
view.setUint8(1, 0x02); // Sub-type
view.setFloat32(2, 3.14, true); // Payload (little-endian)
view.setUint16(6, 42, true); // Sequence number
ws.send(buffer);
// TypedArray (sends underlying buffer)
const positions = new Float32Array([1.0, 2.5, 3.7, 0.0, -1.2, 4.8]);
ws.send(positions);
// Blob from file input
fileInput.onchange = (e) => {
const file = e.target.files[0];
ws.send(file); // File extends Blob
};
// Combine header + payload
function sendWithHeader(ws, type, payload) {
const header = new Uint8Array([type, payload.byteLength >> 8,
payload.byteLength & 0xff]);
const frame = new Uint8Array(header.length + payload.byteLength);
frame.set(header);
frame.set(new Uint8Array(payload), header.length);
ws.send(frame);
}
ws.binaryType = 'arraybuffer';
ws.onmessage = (event) => {
if (typeof event.data === 'string') {
// Text frame
handleText(JSON.parse(event.data));
return;
}
// Binary frame (ArrayBuffer)
const view = new DataView(event.data);
const msgType = view.getUint8(0);
switch (msgType) {
case 0x01: // Position update
const x = view.getFloat32(1, true);
const y = view.getFloat32(5, true);
updatePosition(x, y);
break;
case 0x02: // Audio chunk
const audio = new Uint8Array(event.data, 1);
playAudioChunk(audio);
break;
case 0x03: // Image data
const blob = new Blob([event.data.slice(1)], { type: 'image/png' });
displayImage(URL.createObjectURL(blob));
break;
}
};
// Using protobuf.js
import protobuf from 'protobufjs';
const root = await protobuf.load('messages.proto');
const ChatMessage = root.lookupType('ChatMessage');
// Send
function sendProto(ws, message) {
const errMsg = ChatMessage.verify(message);
if (errMsg) throw Error(errMsg);
const buffer = ChatMessage.encode(
ChatMessage.create(message)
).finish();
ws.send(buffer);
}
// Receive
ws.binaryType = 'arraybuffer';
ws.onmessage = (e) => {
const msg = ChatMessage.decode(new Uint8Array(e.data));
console.log(msg.user, msg.text, msg.timestamp);
};
The browser WebSocket API does not support custom headers. Here are the proven patterns for authenticating WebSocket connections.
The simplest approach. Pass a JWT or session token as a URL parameter.
// Client
const token = getAuthToken();
const ws = new WebSocket(`wss://api.example.com/ws?token=${token}`);
// Server (Node.js + ws)
wss.on('connection', (ws, req) => {
const url = new URL(req.url, 'wss://localhost');
const token = url.searchParams.get('token');
try {
const user = jwt.verify(token, SECRET);
ws.userId = user.id;
} catch {
ws.close(4001, 'Invalid token');
return;
}
});
Connect first, then send credentials as the first message. Reject unauthenticated connections after a timeout.
// Client
const ws = new WebSocket('wss://api.example.com/ws');
ws.onopen = () => {
ws.send(JSON.stringify({
type: 'auth',
token: getAuthToken()
}));
};
// Server
wss.on('connection', (ws) => {
ws.isAuthenticated = false;
// Require auth within 5 seconds
const authTimeout = setTimeout(() => {
if (!ws.isAuthenticated) {
ws.close(4002, 'Authentication timeout');
}
}, 5000);
ws.on('message', (data) => {
const msg = JSON.parse(data);
if (!ws.isAuthenticated) {
if (msg.type === 'auth') {
try {
const user = jwt.verify(msg.token, SECRET);
ws.isAuthenticated = true;
ws.userId = user.id;
clearTimeout(authTimeout);
ws.send(JSON.stringify({ type: 'auth_ok' }));
} catch {
ws.close(4001, 'Invalid credentials');
}
}
return; // Ignore other messages until authenticated
}
// Handle authenticated messages...
handleMessage(ws, msg);
});
});
// If the WebSocket server is same-origin, cookies
// are sent automatically with the upgrade request.
const ws = new WebSocket('wss://example.com/ws');
// Browser sends session cookie with the handshake
// Server (Express + express-session + ws)
import session from 'express-session';
const sessionParser = session({
secret: 'keyboard cat',
resave: false,
saveUninitialized: false
});
// Parse the session from the upgrade request
server.on('upgrade', (req, socket, head) => {
sessionParser(req, {}, () => {
if (!req.session.userId) {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
socket.destroy();
return;
}
wss.handleUpgrade(req, socket, head, (ws) => {
wss.emit('connection', ws, req);
});
});
});
// 1. Client requests a WebSocket ticket via REST
const res = await fetch('/api/ws-ticket', {
headers: { 'Authorization': `Bearer ${jwt}` }
});
const { ticket } = await res.json();
// 2. Connect with the one-time ticket
const ws = new WebSocket(`wss://api.example.com/ws?ticket=${ticket}`);
// 3. Server validates and invalidates the ticket
wss.on('connection', async (ws, req) => {
const ticket = new URL(req.url, 'wss://x').searchParams.get('ticket');
const userId = await redis.getdel(`ws-ticket:${ticket}`);
if (!userId) {
ws.close(4001, 'Invalid or expired ticket');
return;
}
ws.userId = userId;
});
A single server can handle 10K-1M+ connections. But scaling horizontally across multiple servers requires coordination for broadcasts and state.
// Single server: easy broadcast
wss.clients.forEach(client => client.send(msg));
// Multiple servers behind a load balancer?
// Server A has User 1, 2, 3
// Server B has User 4, 5, 6
// How does Server A's broadcast reach User 4?
// Answer: a pub/sub backbone (Redis, NATS, Kafka, etc.)
Load balancers must route a client to the same WebSocket server. Standard round-robin breaks long-lived connections.
# nginx sticky sessions (ip_hash)
upstream websocket_servers {
ip_hash;
server ws1.internal:8080;
server ws2.internal:8080;
server ws3.internal:8080;
}
server {
location /ws {
proxy_pass http://websocket_servers;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 86400s; # 24h for long-lived WS
proxy_send_timeout 86400s;
}
}
import Redis from 'ioredis';
const pub = new Redis();
const sub = new Redis();
// Subscribe to broadcast channels
sub.subscribe('chat:general', 'chat:announcements');
sub.on('message', (channel, message) => {
// Forward to all local WebSocket clients in this channel
const room = rooms.get(channel);
if (room) {
room.forEach(ws => ws.send(message));
}
});
// When a local client sends a message, publish to Redis
wss.on('connection', (ws) => {
ws.on('message', (data) => {
const msg = JSON.parse(data);
// Publish to Redis so ALL servers see it
pub.publish(`chat:${msg.room}`, JSON.stringify({
user: ws.userId,
text: msg.text,
timestamp: Date.now()
}));
});
});
// Socket.IO handles scaling concerns out of the box
import { Server } from 'socket.io';
import { createAdapter } from '@socket.io/redis-adapter';
import { createClient } from 'redis';
const io = new Server(server, {
cors: { origin: '*' }
});
// Redis adapter for multi-server coordination
const pubClient = createClient({ url: 'redis://redis:6379' });
const subClient = pubClient.duplicate();
await Promise.all([pubClient.connect(), subClient.connect()]);
io.adapter(createAdapter(pubClient, subClient));
io.on('connection', (socket) => {
// Rooms are automatically synced across servers
socket.join('room:lobby');
socket.on('chat', (msg) => {
// This reaches ALL clients in the room,
// even on other servers
io.to('room:lobby').emit('chat', {
user: socket.userId,
msg
});
});
// Private message to a specific user
socket.on('dm', ({ to, msg }) => {
io.to(`user:${to}`).emit('dm', {
from: socket.userId,
msg
});
});
});
| Concern | Solution |
|---|---|
| Connection routing | Sticky sessions (cookie or header-based) |
| Cross-server broadcast | Redis Pub/Sub, NATS, or Kafka |
| Connection limits | Tune OS: ulimit -n, net.core.somaxconn |
| Memory per connection | ~10-50KB each; 100K conns ~ 1-5GB RAM |
| SSL termination | At load balancer (nginx, HAProxy, AWS ALB) |
| Graceful shutdown | Drain connections with 1013 (Try Again Later) |
| Horizontal scaling | Stateless servers + shared pub/sub backbone |
Server-Sent Events (SSE) is the simpler alternative when you only need server-to-client streaming. Here is when to use each.
| Feature | WebSocket | SSE (EventSource) |
|---|---|---|
| Direction | Full-duplex | Server-to-client only |
| Protocol | ws:// / wss:// | Standard HTTP/HTTPS |
| Binary data | Yes (native) | No (text only, base64 workaround) |
| Auto-reconnect | Manual | Built-in |
| Resumption | Manual (last-event-id) | Built-in (Last-Event-ID header) |
| Max connections | No browser limit | 6 per domain (HTTP/1.1) |
| Proxy-friendly | Needs Upgrade support | Works everywhere (plain HTTP) |
| Compression | permessage-deflate | Standard gzip/br |
| Browser support | All modern | All modern (no IE) |
| Complexity | Higher | Lower |
// Server (Node.js)
app.get('/events', (req, res) => {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive'
});
// Send event every 2 seconds
const id = setInterval(() => {
res.write(`id: ${Date.now()}\n`);
res.write(`event: price-update\n`);
res.write(`data: ${JSON.stringify({ AAPL: 185.42 })}\n\n`);
}, 2000);
req.on('close', () => clearInterval(id));
});
// Client (Browser)
const source = new EventSource('/events');
source.addEventListener('price-update', (e) => {
const data = JSON.parse(e.data);
console.log('Price:', data.AAPL);
});
// Auto-reconnects on disconnect!
// Sends Last-Event-ID header to resume from where it left off
Chat applications, multiplayer games, collaborative editing, real-time trading, IoT dashboards, binary streaming, two-way communication
News feeds, stock tickers, notification streams, log tailing, progress updates, AI response streaming, social media timelines
Legacy browser support, corporate proxy environments that block WebSocket/SSE, very low frequency updates
Multiplexed request/response, gRPC-Web, environments where WebSocket Upgrade is blocked
Tools and techniques for inspecting, testing, and debugging WebSocket connections in development and production.
// Chrome / Edge / Firefox DevTools
// 1. Open F12 > Network tab
// 2. Filter by "WS" (WebSocket)
// 3. Click on a WebSocket connection to inspect:
// - Headers: Upgrade request/response
// - Messages: Each frame with timestamp, direction, size
// - Timing: Connection and first-frame latency
// Programmatic debugging in console:
const ws = new WebSocket('wss://example.com/ws');
// Log every frame
const origSend = ws.send.bind(ws);
ws.send = (data) => {
console.log('%cSENT', 'color: #28f3ff', data);
origSend(data);
};
ws.addEventListener('message', (e) => {
console.log('%cRECV', 'color: #ff3b8d', e.data);
});
npx wscat -c wss://echo.websocket.org
npx wscat -l 8080
Interactive CLI client and server. Best for quick testing.
websocat ws://localhost:8080/ws
echo "hello" | websocat ws://...
Pipe-friendly, supports binary, TLS, Unix sockets. Install via cargo or brew.
pip install websocket-client
wsdump wss://echo.websocket.org
Shows raw frames with timestamps and opcodes.
Filter: websocket
Deep packet inspection of WebSocket frames. Decodes opcodes, payloads, and close codes.
// Jest / Vitest with mock WebSocket
import { vi, describe, it, expect } from 'vitest';
class MockWebSocket {
constructor(url) {
this.url = url;
this.readyState = WebSocket.CONNECTING;
this.sent = [];
setTimeout(() => {
this.readyState = WebSocket.OPEN;
this.onopen?.({});
}, 0);
}
send(data) { this.sent.push(data); }
close(code, reason) {
this.readyState = WebSocket.CLOSED;
this.onclose?.({ code, reason, wasClean: true });
}
// Simulate server message
_receive(data) {
this.onmessage?.({ data });
}
}
describe('ChatClient', () => {
it('sends auth on connect', async () => {
global.WebSocket = MockWebSocket;
const client = new ChatClient('wss://test/ws', 'my-token');
await new Promise(r => setTimeout(r, 10));
const authMsg = JSON.parse(client.ws.sent[0]);
expect(authMsg.type).toBe('auth');
expect(authMsg.token).toBe('my-token');
});
it('reconnects on abnormal close', async () => {
global.WebSocket = MockWebSocket;
const client = new ChatClient('wss://test/ws', 'tok');
await new Promise(r => setTimeout(r, 10));
client.ws.onclose({ code: 1006, wasClean: false });
// Should schedule reconnect
expect(client.retryCount).toBe(1);
});
});
# Artillery (Node.js)
npm install -g artillery
cat > ws-load.yml << 'YAML'
config:
target: "wss://api.example.com"
phases:
- duration: 60
arrivalRate: 100 # 100 new connections/sec
maxVusers: 5000
ws:
rejectUnauthorized: false
scenarios:
- engine: ws
flow:
- send: '{"type":"auth","token":"test"}'
- think: 1
- send: '{"type":"chat","msg":"hello"}'
- think: 5
- send: '{"type":"chat","msg":"world"}'
- think: 10
YAML
artillery run ws-load.yml
# k6 (Go-based, great for CI)
# k6 run ws-test.js
| Symptom | Likely Cause | Fix |
|---|---|---|
| Connection closes immediately | Proxy not forwarding Upgrade | Configure proxy with proxy_http_version 1.1 and Upgrade headers |
| Connection drops after 60s | Proxy/LB idle timeout | Implement ping/pong heartbeat < timeout interval |
| Close code 1006 | Abnormal closure (no close frame) | Check network, server crashes, proxy timeouts |
| Close code 1009 | Message too big | Increase server maxPayload or chunk data |
SecurityError on connect | Mixed content (ws:// on HTTPS page) | Use wss:// on HTTPS pages |
| Messages arrive out of order | Multiple connections or async handlers | Sequence numbers + reorder buffer |
| Memory leak (server) | Not cleaning up closed connections | Remove from Maps/Sets in onclose |
| CORS errors | Origin not allowed | Server: validate Origin header in verifyClient |