WebSockets
WebSocket is a protocol that provides full-duplex communication over a single TCP connection. Unlike HTTP request-response cycles, WebSockets maintain a persistent connection between client and server, allowing either side to send data at any time with minimal overhead.
WebSockets are ideal for real-time applications like chat systems, live dashboards, multiplayer games, collaborative editing, and streaming data feeds. The protocol starts as an HTTP upgrade handshake, then transitions to a lightweight binary or text frame protocol for the duration of the connection.
The browser API for WebSockets is straightforward. Create a connection with a URL, listen for open/message/error/close events, and send data with the send method. The URL scheme is ws:// for unencrypted or wss:// for encrypted connections.
| 1 | // Basic WebSocket connection |
| 2 | const ws = new WebSocket('wss://echo.websocket.org'); |
| 3 | |
| 4 | ws.addEventListener('open', (event) => { |
| 5 | console.log('Connected to server'); |
| 6 | ws.send('Hello, Server!'); |
| 7 | }); |
| 8 | |
| 9 | ws.addEventListener('message', (event) => { |
| 10 | console.log('Received:', event.data); |
| 11 | }); |
| 12 | |
| 13 | ws.addEventListener('close', (event) => { |
| 14 | console.log('Disconnected:', event.code, event.reason); |
| 15 | }); |
| 16 | |
| 17 | ws.addEventListener('error', (event) => { |
| 18 | console.error('WebSocket error:', event); |
| 19 | }); |
| 20 | |
| 21 | // Sending different data types |
| 22 | ws.send('Plain text message'); |
| 23 | |
| 24 | ws.send(JSON.stringify({ |
| 25 | type: 'chat', |
| 26 | user: 'alice', |
| 27 | text: 'Hello everyone!', |
| 28 | timestamp: Date.now(), |
| 29 | })); |
| 30 | |
| 31 | // Send binary data |
| 32 | const buffer = new ArrayBuffer(8); |
| 33 | const view = new DataView(buffer); |
| 34 | view.setFloat64(0, 3.14159); |
| 35 | ws.send(buffer); |
| 36 | |
| 37 | // Send ArrayBuffer or Blob |
| 38 | const blob = new Blob(['file data'], { type: 'application/octet-stream' }); |
| 39 | ws.send(blob); |
info
The WebSocket protocol (RFC 6455) begins with an HTTP/1.1 upgrade handshake. The client sends an Upgrade header and the server responds with 101 Switching Protocols. After the handshake, the connection operates at the TCP level with framed messages.
| 1 | // WebSocket handshake (HTTP upgrade) |
| 2 | // Client request: |
| 3 | GET /chat HTTP/1.1 |
| 4 | Host: example.com |
| 5 | Upgrade: websocket |
| 6 | Connection: Upgrade |
| 7 | Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ== |
| 8 | Sec-WebSocket-Version: 13 |
| 9 | Origin: https://example.com |
| 10 | |
| 11 | // Server response: |
| 12 | HTTP/1.1 101 Switching Protocols |
| 13 | Upgrade: websocket |
| 14 | Connection: Upgrade |
| 15 | Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo= |
| 16 | |
| 17 | // After handshake, data flows as frames: |
| 18 | // Frame format: |
| 19 | // 0 1 2 3 |
| 20 | // 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 |
| 21 | // +-+-+-+-+-------+-+-------------+-------------------------------+ |
| 22 | // |F|R|R|R| opcode|M| Payload len | Extended payload length | |
| 23 | // |I|S|S|S| (4) |A| (7) | (16/64) | |
| 24 | // |N|V|V|V| |S| | (if payload len==126/127) | |
| 25 | // | |1|2|3| |K| | | |
| 26 | // +-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - + |
| 27 | // | Extended payload length continued, if payload len == 127 | |
| 28 | // + - - - - - - - - - - - - - - - +-------------------------------+ |
| 29 | // | |Masking-key, if MASK set to 1 | |
| 30 | // +-------------------------------+-------------------------------+ |
| 31 | // | Masking-key (continued) | Payload Data | |
| 32 | // +-------------------------------- - - - - - - - - - - - - - - - + |
| 33 | // : Payload Data continued ... : |
| 34 | // + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -+ |
| 35 | // | Payload Data (continued) | |
| 36 | // +---------------------------------------------------------------+ |
| 37 | |
| 38 | // Opcodes: |
| 39 | // 0x0 — Continuation frame |
| 40 | // 0x1 — Text frame (UTF-8) |
| 41 | // 0x2 — Binary frame |
| 42 | // 0x8 — Connection close |
| 43 | // 0x9 — Ping |
| 44 | // 0xA — Pong |
best practice
WebSocket connections can drop due to network issues, server restarts, or timeouts. A robust reconnection strategy is essential for production apps. Use exponential backoff with jitter to avoid thundering herd problems.
| 1 | class ReconnectingWebSocket { |
| 2 | constructor(url, protocols) { |
| 3 | this.url = url; |
| 4 | this.protocols = protocols; |
| 5 | this.reconnectAttempts = 0; |
| 6 | this.maxReconnectAttempts = 20; |
| 7 | this.baseDelay = 1000; |
| 8 | this.maxDelay = 30000; |
| 9 | this.listeners = {}; |
| 10 | this.connect(); |
| 11 | } |
| 12 | |
| 13 | connect() { |
| 14 | this.ws = new WebSocket(this.url, this.protocols); |
| 15 | |
| 16 | this.ws.addEventListener('open', () => { |
| 17 | this.reconnectAttempts = 0; |
| 18 | this.emit('open'); |
| 19 | }); |
| 20 | |
| 21 | this.ws.addEventListener('message', (event) => { |
| 22 | this.emit('message', event); |
| 23 | }); |
| 24 | |
| 25 | this.ws.addEventListener('close', (event) => { |
| 26 | this.emit('close', event); |
| 27 | if (!event.wasClean) { |
| 28 | this.scheduleReconnect(); |
| 29 | } |
| 30 | }); |
| 31 | |
| 32 | this.ws.addEventListener('error', (event) => { |
| 33 | this.emit('error', event); |
| 34 | }); |
| 35 | } |
| 36 | |
| 37 | scheduleReconnect() { |
| 38 | if (this.reconnectAttempts >= this.maxReconnectAttempts) { |
| 39 | this.emit('reconnectFailed'); |
| 40 | return; |
| 41 | } |
| 42 | |
| 43 | // Exponential backoff with jitter |
| 44 | const delay = Math.min( |
| 45 | this.baseDelay * Math.pow(2, this.reconnectAttempts) + Math.random() * 1000, |
| 46 | this.maxDelay |
| 47 | ); |
| 48 | |
| 49 | this.reconnectAttempts++; |
| 50 | console.log( |
| 51 | `Reconnecting in ${Math.round(delay)}ms ` |
| 52 | + `(${this.reconnectAttempts}/${this.maxReconnectAttempts})` |
| 53 | ); |
| 54 | |
| 55 | this.reconnectTimer = setTimeout(() => { |
| 56 | this.connect(); |
| 57 | }, delay); |
| 58 | } |
| 59 | |
| 60 | send(data) { |
| 61 | if (this.ws.readyState === WebSocket.OPEN) { |
| 62 | this.ws.send(data); |
| 63 | } else { |
| 64 | console.warn('WebSocket not open, queuing message'); |
| 65 | this.queue = this.queue || []; |
| 66 | this.queue.push(data); |
| 67 | } |
| 68 | } |
| 69 | |
| 70 | on(event, callback) { |
| 71 | if (!this.listeners[event]) this.listeners[event] = []; |
| 72 | this.listeners[event].push(callback); |
| 73 | } |
| 74 | |
| 75 | emit(event, ...args) { |
| 76 | (this.listeners[event] || []).forEach((cb) => cb(...args)); |
| 77 | // Flush queued messages on reconnect |
| 78 | if (event === 'open' && this.queue?.length) { |
| 79 | this.queue.forEach((msg) => this.send(msg)); |
| 80 | this.queue = []; |
| 81 | } |
| 82 | } |
| 83 | |
| 84 | close() { |
| 85 | clearTimeout(this.reconnectTimer); |
| 86 | this.maxReconnectAttempts = 0; |
| 87 | this.ws.close(); |
| 88 | } |
| 89 | } |
| 90 | |
| 91 | // Usage |
| 92 | const ws = new ReconnectingWebSocket('wss://api.example.com/ws'); |
| 93 | ws.on('message', (event) => { |
| 94 | const data = JSON.parse(event.data); |
| 95 | handleUpdate(data); |
| 96 | }); |
| 97 | ws.on('reconnectFailed', () => { |
| 98 | showOfflineMessage(); |
| 99 | }); |
warning
WebSocket rooms and channels let you organize connections into logical groups so messages are only delivered to relevant clients. This is typically implemented server-side since the browser API is a single connection.
| 1 | // Server-side (Node.js with ws library) |
| 2 | const WebSocket = require('ws'); |
| 3 | const wss = new WebSocket.Server({ port: 8080 }); |
| 4 | |
| 5 | const rooms = new Map(); // roomId -> Set<ws> |
| 6 | const clientRooms = new Map(); // ws -> Set<roomId> |
| 7 | |
| 8 | wss.on('connection', (ws) => { |
| 9 | clientRooms.set(ws, new Set()); |
| 10 | |
| 11 | ws.on('message', (raw) => { |
| 12 | const msg = JSON.parse(raw); |
| 13 | |
| 14 | switch (msg.type) { |
| 15 | case 'join': |
| 16 | joinRoom(ws, msg.room); |
| 17 | break; |
| 18 | case 'leave': |
| 19 | leaveRoom(ws, msg.room); |
| 20 | break; |
| 21 | case 'message': |
| 22 | broadcastToRoom(msg.room, { |
| 23 | type: 'message', |
| 24 | room: msg.room, |
| 25 | user: msg.user, |
| 26 | text: msg.text, |
| 27 | timestamp: Date.now(), |
| 28 | }, ws); |
| 29 | break; |
| 30 | } |
| 31 | }); |
| 32 | |
| 33 | ws.on('close', () => { |
| 34 | const roomSet = clientRooms.get(ws); |
| 35 | if (roomSet) { |
| 36 | roomSet.forEach((roomId) => leaveRoom(ws, roomId)); |
| 37 | } |
| 38 | clientRooms.delete(ws); |
| 39 | }); |
| 40 | }); |
| 41 | |
| 42 | function joinRoom(ws, roomId) { |
| 43 | if (!rooms.has(roomId)) rooms.set(roomId, new Set()); |
| 44 | rooms.get(roomId).add(ws); |
| 45 | clientRooms.get(ws).add(roomId); |
| 46 | |
| 47 | ws.send(JSON.stringify({ |
| 48 | type: 'joined', |
| 49 | room: roomId, |
| 50 | users: rooms.get(roomId).size, |
| 51 | })); |
| 52 | |
| 53 | broadcastToRoom(roomId, { |
| 54 | type: 'userJoined', |
| 55 | room: roomId, |
| 56 | users: rooms.get(roomId).size, |
| 57 | }, ws); |
| 58 | } |
| 59 | |
| 60 | function leaveRoom(ws, roomId) { |
| 61 | const room = rooms.get(roomId); |
| 62 | if (room) { |
| 63 | room.delete(ws); |
| 64 | clientRooms.get(ws)?.delete(roomId); |
| 65 | if (room.size === 0) rooms.delete(roomId); |
| 66 | } |
| 67 | } |
| 68 | |
| 69 | function broadcastToRoom(roomId, message, exclude) { |
| 70 | const room = rooms.get(roomId); |
| 71 | if (!room) return; |
| 72 | const data = JSON.stringify(message); |
| 73 | room.forEach((client) => { |
| 74 | if (client !== exclude && client.readyState === WebSocket.OPEN) { |
| 75 | client.send(data); |
| 76 | } |
| 77 | }); |
| 78 | } |
| 79 | |
| 80 | // Client-side — join and use rooms |
| 81 | const ws = new WebSocket('wss://api.example.com/ws'); |
| 82 | |
| 83 | ws.onopen = () => { |
| 84 | ws.send(JSON.stringify({ type: 'join', room: 'general' })); |
| 85 | ws.send(JSON.stringify({ type: 'join', room: 'notifications' })); |
| 86 | }; |
| 87 | |
| 88 | function sendToRoom(room, text) { |
| 89 | ws.send(JSON.stringify({ |
| 90 | type: 'message', |
| 91 | room, |
| 92 | user: currentUser, |
| 93 | text, |
| 94 | })); |
| 95 | } |
info
The BroadcastChannel API enables communication between different tabs, windows, and iframes of the same origin. Unlike WebSockets (which go through a server), BroadcastChannel is purely client-side and useful for synchronizing state across browser instances.
| 1 | // Create a channel (same name in all tabs) |
| 2 | const channel = new BroadcastChannel('app-state'); |
| 3 | |
| 4 | // Send messages to all other tabs |
| 5 | channel.postMessage({ |
| 6 | type: 'STATE_UPDATE', |
| 7 | payload: { theme: 'dark', language: 'en' }, |
| 8 | }); |
| 9 | |
| 10 | channel.postMessage({ |
| 11 | type: 'LOGOUT', |
| 12 | userId: 'user-123', |
| 13 | }); |
| 14 | |
| 15 | // Listen for messages from other tabs |
| 16 | channel.onmessage = (event) => { |
| 17 | const { type, payload } = event.data; |
| 18 | switch (type) { |
| 19 | case 'STATE_UPDATE': |
| 20 | applyStateUpdate(payload); |
| 21 | break; |
| 22 | case 'LOGOUT': |
| 23 | if (payload.userId === currentUser.id) { |
| 24 | window.location.href = '/login'; |
| 25 | } |
| 26 | break; |
| 27 | } |
| 28 | }; |
| 29 | |
| 30 | // Close when tab closes |
| 31 | channel.close(); |
| 32 | |
| 33 | // Advanced: Cross-tab event synchronization |
| 34 | class CrossTabSync { |
| 35 | constructor(channelName) { |
| 36 | this.channel = new BroadcastChannel(channelName); |
| 37 | this.pendingAcks = new Map(); |
| 38 | this.channel.onmessage = (e) => this.handleMessage(e.data); |
| 39 | } |
| 40 | |
| 41 | broadcast(type, data) { |
| 42 | const id = crypto.randomUUID(); |
| 43 | this.channel.postMessage({ type, data, id, origin: this.tabId }); |
| 44 | return new Promise((resolve) => { |
| 45 | this.pendingAcks.set(id, { resolve, timestamp: Date.now() }); |
| 46 | setTimeout(() => this.pendingAcks.delete(id), 5000); |
| 47 | }); |
| 48 | } |
| 49 | |
| 50 | handleMessage(msg) { |
| 51 | if (msg.origin === this.tabId) return; |
| 52 | if (msg.type === 'ACK' && this.pendingAcks.has(msg.id)) { |
| 53 | this.pendingAcks.get(msg.id).resolve(); |
| 54 | this.pendingAcks.delete(msg.id); |
| 55 | return; |
| 56 | } |
| 57 | // Process the message |
| 58 | this.channel.postMessage({ |
| 59 | type: 'ACK', |
| 60 | id: msg.id, |
| 61 | origin: this.tabId, |
| 62 | }); |
| 63 | } |
| 64 | |
| 65 | get tabId() { |
| 66 | return this._tabId || (this._tabId = crypto.randomUUID()); |
| 67 | } |
| 68 | } |
best practice
WebSocket connections cannot set custom headers during the initial handshake, so authentication must use query parameters, cookies, or send a token as the first message after connection. Always validate tokens server-side.
| 1 | // Method 1: Token in query string (less secure — visible in logs) |
| 2 | const token = await getAuthToken(); |
| 3 | const ws = new WebSocket( |
| 4 | `wss://api.example.com/ws?token=${encodeURIComponent(token)}` |
| 5 | ); |
| 6 | |
| 7 | // Method 2: Cookie-based auth (works with SameSite cookies) |
| 8 | // If the WS server is on the same origin, cookies are sent automatically |
| 9 | const ws = new WebSocket('wss://api.example.com/ws'); |
| 10 | |
| 11 | // Method 3: Send token as first message (most common) |
| 12 | const ws = new WebSocket('wss://api.example.com/ws'); |
| 13 | |
| 14 | ws.addEventListener('open', async () => { |
| 15 | const token = await getAuthToken(); |
| 16 | ws.send(JSON.stringify({ |
| 17 | type: 'auth', |
| 18 | token, |
| 19 | })); |
| 20 | }); |
| 21 | |
| 22 | // Server-side validation (Node.js) |
| 23 | wss.on('connection', (ws, req) => { |
| 24 | let authenticated = false; |
| 25 | const authTimeout = setTimeout(() => { |
| 26 | if (!authenticated) { |
| 27 | ws.close(4001, 'Authentication timeout'); |
| 28 | } |
| 29 | }, 5000); |
| 30 | |
| 31 | ws.on('message', (raw) => { |
| 32 | const msg = JSON.parse(raw); |
| 33 | |
| 34 | if (!authenticated) { |
| 35 | if (msg.type === 'auth') { |
| 36 | const user = verifyToken(msg.token); |
| 37 | if (user) { |
| 38 | authenticated = true; |
| 39 | clearTimeout(authTimeout); |
| 40 | ws.user = user; |
| 41 | ws.send(JSON.stringify({ type: 'authSuccess', user: user.id })); |
| 42 | } else { |
| 43 | ws.close(4003, 'Invalid token'); |
| 44 | } |
| 45 | } |
| 46 | return; |
| 47 | } |
| 48 | |
| 49 | // Handle authenticated messages |
| 50 | handleMessage(ws, msg); |
| 51 | }); |
| 52 | }); |
| 53 | |
| 54 | // Rate limiting WebSocket messages |
| 55 | const rateLimiter = new Map(); |
| 56 | |
| 57 | function checkRateLimit(ws, maxPerSecond = 50) { |
| 58 | const userId = ws.user.id; |
| 59 | const now = Date.now(); |
| 60 | const window = rateLimiter.get(userId) || { count: 0, start: now }; |
| 61 | |
| 62 | if (now - window.start > 1000) { |
| 63 | window.count = 0; |
| 64 | window.start = now; |
| 65 | } |
| 66 | |
| 67 | window.count++; |
| 68 | rateLimiter.set(userId, window); |
| 69 | |
| 70 | if (window.count > maxPerSecond) { |
| 71 | ws.send(JSON.stringify({ type: 'error', message: 'Rate limit exceeded' })); |
| 72 | return false; |
| 73 | } |
| 74 | return true; |
| 75 | } |
warning
WebSockets support both text (UTF-8) and binary frames. For high-throughput scenarios, binary data with protocols like MessagePack or Protocol Buffers reduces serialization overhead compared to JSON.
| 1 | // Binary protocol with ArrayBuffer |
| 2 | function sendBinaryVector(ws, x, y, z) { |
| 3 | const buffer = new ArrayBuffer(12); |
| 4 | const view = new DataView(buffer); |
| 5 | view.setFloat32(0, x); |
| 6 | view.setFloat32(4, y); |
| 7 | view.setFloat32(8, z); |
| 8 | ws.send(buffer); |
| 9 | } |
| 10 | |
| 11 | function parseBinaryVector(data) { |
| 12 | const view = new DataView(data); |
| 13 | return { |
| 14 | x: view.getFloat32(0), |
| 15 | y: view.getFloat32(4), |
| 16 | z: view.getFloat32(8), |
| 17 | }; |
| 18 | } |
| 19 | |
| 20 | // MessagePack — compact binary JSON alternative |
| 21 | // npm install @msgpack/msgpack |
| 22 | import { encode, decode } from '@msgpack/msgpack'; |
| 23 | |
| 24 | // Encode and send |
| 25 | const message = { type: 'position', x: 1.5, y: 2.3, z: -0.7 }; |
| 26 | ws.send(encode(message)); |
| 27 | |
| 28 | // Receive and decode |
| 29 | ws.binaryType = 'arraybuffer'; |
| 30 | ws.onmessage = (event) => { |
| 31 | const data = decode(new Uint8Array(event.data)); |
| 32 | console.log(data); // { type: 'position', x: 1.5, y: 2.3, z: -0.7 } |
| 33 | }; |
| 34 | |
| 35 | // Compression for large payloads |
| 36 | async function sendCompressed(ws, data) { |
| 37 | const json = JSON.stringify(data); |
| 38 | const stream = new Blob([json]).stream(); |
| 39 | const compressed = stream.pipeThrough( |
| 40 | new CompressionStream('gzip') |
| 41 | ); |
| 42 | const buffer = await new Response(compressed).arrayBuffer(); |
| 43 | ws.send(buffer); |
| 44 | } |
| 45 | |
| 46 | // Throttle high-frequency updates |
| 47 | function createThrottledSender(ws, maxHz = 60) { |
| 48 | let lastSend = 0; |
| 49 | let pendingData = null; |
| 50 | |
| 51 | return function send(data) { |
| 52 | pendingData = data; |
| 53 | const now = performance.now(); |
| 54 | const interval = 1000 / maxHz; |
| 55 | |
| 56 | if (now - lastSend >= interval) { |
| 57 | lastSend = now; |
| 58 | ws.send(typeof data === 'string' ? data : JSON.stringify(data)); |
| 59 | pendingData = null; |
| 60 | } else if (!pendingData?._scheduled) { |
| 61 | pendingData._scheduled = true; |
| 62 | setTimeout(() => { |
| 63 | if (pendingData) { |
| 64 | lastSend = performance.now(); |
| 65 | ws.send( |
| 66 | typeof pendingData === 'string' |
| 67 | ? pendingData |
| 68 | : JSON.stringify(pendingData) |
| 69 | ); |
| 70 | pendingData = null; |
| 71 | } |
| 72 | }, interval - (now - lastSend)); |
| 73 | } |
| 74 | }; |
| 75 | } |
pro tip
While the browser provides a simple WebSocket API, server-side libraries add critical features like rooms, namespaces, middleware, automatic reconnection, and transport fallbacks. Here are the most popular options.
| 1 | // ws — Lightweight, no-frills (Node.js) |
| 2 | const WebSocket = require('ws'); |
| 3 | const wss = new WebSocket.Server({ port: 8080 }); |
| 4 | wss.on('connection', (ws) => { |
| 5 | ws.on('message', (data) => ws.send(data)); // echo |
| 6 | }); |
| 7 | |
| 8 | // Socket.IO — Full-featured with fallbacks |
| 9 | const { Server } = require('socket.io'); |
| 10 | const io = new Server(3000, { |
| 11 | cors: { origin: 'https://example.com' }, |
| 12 | }); |
| 13 | |
| 14 | io.on('connection', (socket) => { |
| 15 | socket.join('general'); |
| 16 | socket.to('general').emit('message', 'User joined'); |
| 17 | socket.on('chat', (msg) => io.to('general').emit('chat', msg)); |
| 18 | }); |
| 19 | |
| 20 | // µWebSockets.js — High performance (10x faster than ws) |
| 21 | const uWS = require('uWebSockets.js'); |
| 22 | uWS.App() |
| 23 | .ws('/*', { |
| 24 | message: (ws, message) => { |
| 25 | ws.send(message); |
| 26 | }, |
| 27 | }) |
| 28 | .listen(9001); |
| 29 | |
| 30 | // Client-side with Socket.IO |
| 31 | import { io } from 'socket.io-client'; |
| 32 | const socket = io('https://api.example.com', { |
| 33 | auth: { token: userToken }, |
| 34 | reconnection: true, |
| 35 | reconnectionDelay: 1000, |
| 36 | reconnectionDelayMax: 5000, |
| 37 | reconnectionAttempts: 20, |
| 38 | }); |
| 39 | |
| 40 | socket.on('connect', () => console.log('Connected')); |
| 41 | socket.on('message', (data) => handleMessage(data)); |
| 42 | socket.emit('join', 'chat-room'); |
| 43 | |
| 44 | // ably/pubnub — Managed WebSocket services |
| 45 | // Provides pub/sub, presence, history, and connection management |
| 46 | // without running your own WebSocket server |
best practice
- WebSockets provide full-duplex communication over a single TCP connection
- Always use wss:// for secure connections in production
- Implement exponential backoff with jitter for reliable reconnection
- Rooms and channels organize messages to relevant clients
- Use BroadcastChannel for cross-tab communication without a server
- Authenticate via cookies or first message — never in query strings
- Binary protocols (MessagePack, Protobuf) outperform JSON for high-throughput