Skip to main content

How to Use WebSockets for Real-Time Communication in JavaScript

Introduction

HTTP follows a strict request-response pattern: the client asks, the server answers, and the connection's job is done. This works perfectly for loading pages and fetching data, but it falls apart when both sides need to talk freely and continuously. Think of a multiplayer game where players' actions must reach every other player instantly, a collaborative document where every keystroke appears on everyone's screen in real time, or a trading platform where price changes must arrive within milliseconds.

WebSocket is a communication protocol that solves this by establishing a persistent, full-duplex connection between the browser and the server. Once opened, either side can send data to the other at any time, without waiting for a request or response. There is no polling, no repeated HTTP handshakes, and no wasted bandwidth. Data flows in both directions simultaneously through a single, long-lived connection with minimal overhead per message.

The WebSocket API in the browser is refreshingly simple. You create a WebSocket object, listen for events, and call send(). The protocol handles framing, connection management, and graceful closure behind the scenes.

In this guide, you will learn what WebSocket is and how it differs from HTTP, how to open connections, send and receive both text and binary data, handle connection lifecycle events, close connections properly with status codes, and build robust real-time features with reconnection logic.

What Is WebSocket?

The Problem with HTTP for Real-Time Communication

HTTP was designed for fetching documents. Each interaction follows the same pattern:

Client: "Give me /page.html"
Server: "Here it is"
(connection is done)

Client: "Give me /api/data"
Server: "Here is the JSON"
(connection is done)

Even with techniques like long polling, every piece of server-to-client communication requires the client to initiate an HTTP request first. The server can never spontaneously send data to the client. Each request also carries significant overhead: HTTP headers, cookies, and connection setup add hundreds of bytes (sometimes kilobytes) to every exchange.

WebSocket: A Different Protocol

WebSocket is a completely different protocol from HTTP, though it starts life as an HTTP request. The browser sends a special HTTP request called an upgrade request, asking the server to switch from HTTP to the WebSocket protocol. If the server agrees, the connection is "upgraded" and both sides can now send messages freely.

1. Client sends HTTP upgrade request:
GET /chat HTTP/1.1
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13

2. Server agrees:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

3. Connection is now WebSocket - both sides send freely:
Client → Server: "Hello!"
Server → Client: "Hi there!"
Server → Client: "New notification for you"
Client → Server: "Thanks, mark it as read"
Server → Client: "Stock AAPL: $178.52"
Server → Client: "Stock AAPL: $178.55"
...

After the handshake, the HTTP layer is gone. Communication happens through lightweight WebSocket frames with just 2 to 14 bytes of overhead per message (compared to hundreds of bytes for HTTP headers).

Key Characteristics

FeatureHTTPWebSocket
CommunicationRequest-response (client initiates)Full-duplex (either side initiates)
ConnectionNew connection per request (or keep-alive)Single persistent connection
Overhead per messageLarge (headers, cookies)Tiny (2-14 bytes framing)
DirectionClient → Server (server responds)Bidirectional simultaneously
Protocolhttp:// / https://ws:// / wss://
StateStatelessStateful (connection persists)

When to Use WebSocket

WebSocket is the right choice when:

  • Both client and server need to send data at any time (chat, collaboration)
  • Data updates are frequent and low-latency matters (gaming, trading, live sports)
  • You need to minimize overhead per message (IoT data streams)
  • The server needs to push data immediately without the client asking (notifications, alerts)

WebSocket is not necessary when:

  • You just need to fetch data on demand (use fetch())
  • Updates are infrequent (use long polling or Server-Sent Events)
  • Communication is only server-to-client (use Server-Sent Events)
  • You need HTTP features like caching, redirects, or content negotiation

Opening a Connection: new WebSocket(url)

Creating a WebSocket connection is a single line of code:

const socket = new WebSocket('wss://example.com/chat');

The URL Scheme

WebSocket uses its own URL schemes:

// Unencrypted (like http://)
const ws = new WebSocket('ws://example.com/socket');

// Encrypted (like https://) - ALWAYS use this in production
const wss = new WebSocket('wss://example.com/socket');
caution

Always use wss:// (WebSocket Secure) in production. Unencrypted ws:// connections are vulnerable to man-in-the-middle attacks and are blocked by many proxies. Just as you would never serve a production website over http://, never use ws:// for real applications. Most browsers also restrict ws:// connections from pages served over https:// due to mixed content policies.

The WebSocket Constructor

The full constructor signature:

new WebSocket(url)
new WebSocket(url, protocols)

url: The WebSocket server URL. Must start with ws:// or wss://.

protocols (optional): A string or array of strings specifying sub-protocols. Sub-protocols let the client and server agree on a specific application-level protocol to use over the WebSocket connection:

// Single sub-protocol
const socket = new WebSocket('wss://example.com/socket', 'chat-v2');

// Multiple sub-protocols - server picks one
const socket = new WebSocket('wss://example.com/socket', ['chat-v2', 'chat-v1']);

// After connection, check which one the server chose
socket.onopen = () => {
console.log('Agreed protocol:', socket.protocol);
// "chat-v2" or "chat-v1" (whichever the server selected)
};

Sub-protocols are useful when your WebSocket server supports multiple application protocols and the client needs to indicate which one it wants. Common examples include graphql-ws, mqtt, wamp, or custom application protocols like chat-v2.

Connection States

The WebSocket object has a readyState property that reflects the current connection state:

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

console.log(socket.readyState);
// 0 = CONNECTING (connection not yet established)
// 1 = OPEN (connection established, ready to communicate)
// 2 = CLOSING (close() has been called, closing in progress)
// 3 = CLOSED (connection is closed or could not be opened)

The constants are also available on the WebSocket constructor:

console.log(WebSocket.CONNECTING); // 0
console.log(WebSocket.OPEN); // 1
console.log(WebSocket.CLOSING); // 2
console.log(WebSocket.CLOSED); // 3

// Check if connected
if (socket.readyState === WebSocket.OPEN) {
socket.send('message');
}

What Happens During Connection

When you create a new WebSocket(), several things happen immediately:

  1. The constructor returns a WebSocket object in the CONNECTING state
  2. The browser initiates a TCP connection to the server
  3. A TLS handshake occurs (for wss://)
  4. The browser sends an HTTP upgrade request
  5. The server responds with 101 Switching Protocols
  6. The connection state changes to OPEN
  7. The open event fires

If any step fails (server unreachable, upgrade rejected, TLS error), the connection state goes to CLOSED and the error and close events fire.

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

console.log(socket.readyState); // 0 (CONNECTING)

socket.onopen = () => {
console.log(socket.readyState); // 1 (OPEN)
};

socket.onclose = () => {
console.log(socket.readyState); // 3 (CLOSED)
};

Events: open, message, error, close

WebSocket communication is event-driven. Four events cover the entire lifecycle.

open: Connection Established

Fires when the connection is successfully established and the WebSocket is ready to send and receive data:

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

socket.addEventListener('open', (event) => {
console.log('Connected to server');
console.log('Protocol:', socket.protocol);

// Safe to send data now
socket.send('Hello, server!');
});

// Or using the event property
socket.onopen = (event) => {
console.log('Connection opened');
};

Important: Do not call socket.send() before the open event fires. The connection is not ready yet:

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

// WRONG: Connection is not open yet
socket.send('Hello'); // May throw or silently fail

// CORRECT: Wait for the connection to open
socket.onopen = () => {
socket.send('Hello'); // Safe
};

message: Data Received

Fires when the server sends data. The message content is in event.data:

socket.addEventListener('message', (event) => {
console.log('Received:', event.data);
console.log('Type:', typeof event.data);
});

The type of event.data depends on what the server sent:

  • String data arrives as a JavaScript string
  • Binary data arrives as a Blob (default) or ArrayBuffer (configurable)
socket.onmessage = (event) => {
if (typeof event.data === 'string') {
// Text message
const data = JSON.parse(event.data);
console.log('Text message:', data);
} else {
// Binary message (Blob or ArrayBuffer)
console.log('Binary message:', event.data);
}
};

Handling JSON Messages

Most WebSocket applications exchange JSON messages:

socket.onmessage = (event) => {
try {
const message = JSON.parse(event.data);

switch (message.type) {
case 'chat':
displayChatMessage(message.sender, message.text);
break;
case 'notification':
showNotification(message.title, message.body);
break;
case 'userJoined':
addUserToList(message.user);
break;
case 'error':
handleServerError(message.code, message.detail);
break;
default:
console.warn('Unknown message type:', message.type);
}
} catch (error) {
console.error('Failed to parse message:', event.data);
}
};

Controlling Binary Data Format

By default, binary data arrives as Blob. You can change this to ArrayBuffer:

// Receive binary data as Blob (default)
socket.binaryType = 'blob';

socket.onmessage = (event) => {
if (event.data instanceof Blob) {
// Read the Blob
const reader = new FileReader();
reader.onload = () => {
console.log('Blob content:', reader.result);
};
reader.readAsText(event.data);
}
};

// Receive binary data as ArrayBuffer
socket.binaryType = 'arraybuffer';

socket.onmessage = (event) => {
if (event.data instanceof ArrayBuffer) {
const bytes = new Uint8Array(event.data);
console.log('Received bytes:', bytes.length);
console.log('First byte:', bytes[0]);
}
};

Use ArrayBuffer when you need direct access to raw bytes (binary protocols, audio/video data, game state). Use Blob when you need to save the data as a file or create an object URL.

error: Something Went Wrong

Fires when an error occurs. The error event in WebSocket is deliberately vague for security reasons. It does not provide detailed error information:

socket.addEventListener('error', (event) => {
console.error('WebSocket error:', event);
// event does not contain a message or error code
// The close event that follows will have more details
});
note

The error event is always followed by a close event. The close event's code and reason properties often provide more useful information about what went wrong. Many developers handle errors primarily in the close event handler.

close: Connection Closed

Fires when the connection is closed, whether cleanly (by either side calling close()) or due to an error:

socket.addEventListener('close', (event) => {
console.log('Connection closed');
console.log('Code:', event.code); // Numeric close code
console.log('Reason:', event.reason); // String explanation
console.log('Clean:', event.wasClean); // Boolean: was it a clean close?
});

The close event properties:

PropertyTypeDescription
event.codenumberWebSocket close code (1000-4999)
event.reasonstringHuman-readable explanation (max 123 bytes)
event.wasCleanbooleantrue if the connection closed cleanly (proper handshake)
socket.onclose = (event) => {
if (event.wasClean) {
console.log(`Clean close: code=${event.code}, reason="${event.reason}"`);
} else {
// Connection died unexpectedly (network failure, server crash)
console.error('Connection lost unexpectedly');
// event.code is usually 1006 for abnormal closures
}
};

Complete Lifecycle Example

function connectWebSocket() {
const socket = new WebSocket('wss://example.com/socket');

socket.onopen = () => {
console.log('[open] Connection established');
socket.send(JSON.stringify({
type: 'auth',
token: 'user-auth-token'
}));
};

socket.onmessage = (event) => {
console.log('[message] Data received:', event.data);
const message = JSON.parse(event.data);
handleMessage(message);
};

socket.onerror = (event) => {
console.error('[error] WebSocket error');
};

socket.onclose = (event) => {
if (event.wasClean) {
console.log(`[close] Clean: code=${event.code}, reason="${event.reason}"`);
} else {
console.log('[close] Connection died');
}
};

return socket;
}

Sending Data: send()

The send() method transmits data to the server. It accepts several data types.

Sending Strings

The most common use case. Almost always, you will send JSON-encoded strings:

// Plain string
socket.send('Hello, server!');

// JSON (most common pattern)
socket.send(JSON.stringify({
type: 'chat',
text: 'Hello everyone!',
timestamp: Date.now()
}));

Sending Binary Data

WebSocket natively supports binary data, which is critical for game state, audio/video, and file transfer:

// ArrayBuffer
const buffer = new ArrayBuffer(8);
const view = new Float64Array(buffer);
view[0] = Math.PI;
socket.send(buffer);

// TypedArray
const bytes = new Uint8Array([0x48, 0x65, 0x6C, 0x6C, 0x6F]);
socket.send(bytes);

// Blob
const blob = new Blob(['file content'], { type: 'text/plain' });
socket.send(blob);

// ArrayBuffer from an existing source
const audioChunk = getAudioData(); // Returns ArrayBuffer
socket.send(audioChunk);

Checking Connection Before Sending

Always verify the connection is open before sending:

function safeSend(socket, data) {
if (socket.readyState === WebSocket.OPEN) {
socket.send(data);
return true;
}

console.warn('Cannot send: WebSocket is not open (state:', socket.readyState, ')');
return false;
}

// Usage
safeSend(socket, JSON.stringify({ type: 'ping' }));

Buffered Amount

The bufferedAmount property tells you how many bytes of data are queued and waiting to be sent. This is useful for flow control when sending large amounts of data:

// Check if previous data has been sent before queuing more
function sendWhenReady(socket, data) {
if (socket.bufferedAmount === 0) {
socket.send(data);
} else {
// Previous data still sending - wait
setTimeout(() => sendWhenReady(socket, data), 100);
}
}

A practical example for streaming data without overwhelming the connection:

async function sendLargeData(socket, data, chunkSize = 16384) {
const chunks = [];

for (let i = 0; i < data.byteLength; i += chunkSize) {
chunks.push(data.slice(i, i + chunkSize));
}

for (const chunk of chunks) {
// Wait until the buffer drains before sending more
while (socket.bufferedAmount > chunkSize * 4) {
await new Promise(resolve => setTimeout(resolve, 50));
}

if (socket.readyState !== WebSocket.OPEN) {
throw new Error('Connection closed during transfer');
}

socket.send(chunk);
}
}

Building a Message Protocol

Real applications need a structured message format. Here is a common pattern:

class WebSocketProtocol {
constructor(socket) {
this.socket = socket;
this.handlers = new Map();
this.pendingRequests = new Map();
this.nextRequestId = 1;

this.socket.onmessage = (event) => {
this.handleIncoming(event.data);
};
}

// Register a handler for a message type
on(type, handler) {
this.handlers.set(type, handler);
}

// Send a message (fire and forget)
send(type, data = {}) {
if (this.socket.readyState !== WebSocket.OPEN) {
console.warn('Socket not open, cannot send:', type);
return;
}

this.socket.send(JSON.stringify({ type, data }));
}

// Send a request and wait for a response (request-response over WebSocket)
request(type, data = {}, timeoutMs = 10000) {
return new Promise((resolve, reject) => {
const id = this.nextRequestId++;

const timer = setTimeout(() => {
this.pendingRequests.delete(id);
reject(new Error(`Request "${type}" timed out after ${timeoutMs}ms`));
}, timeoutMs);

this.pendingRequests.set(id, { resolve, reject, timer });

this.socket.send(JSON.stringify({
type,
data,
id // Include request ID so server can reference it in the response
}));
});
}

handleIncoming(raw) {
let message;
try {
message = JSON.parse(raw);
} catch {
console.error('Invalid message format:', raw);
return;
}

// Check if this is a response to a pending request
if (message.id && this.pendingRequests.has(message.id)) {
const { resolve, reject, timer } = this.pendingRequests.get(message.id);
clearTimeout(timer);
this.pendingRequests.delete(message.id);

if (message.error) {
reject(new Error(message.error));
} else {
resolve(message.data);
}
return;
}

// Otherwise, dispatch to the registered handler
const handler = this.handlers.get(message.type);
if (handler) {
handler(message.data, message);
} else {
console.warn('No handler for message type:', message.type);
}
}
}

Usage:

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

socket.onopen = async () => {
const protocol = new WebSocketProtocol(socket);

// Listen for server-initiated messages
protocol.on('chat', (data) => {
console.log(`${data.sender}: ${data.text}`);
});

protocol.on('userList', (data) => {
updateUserList(data.users);
});

protocol.on('typing', (data) => {
showTypingIndicator(data.user);
});

// Send fire-and-forget messages
protocol.send('chat', { text: 'Hello!' });
protocol.send('typing', { isTyping: true });

// Send request-response style messages
try {
const history = await protocol.request('getHistory', { room: 'general', limit: 50 });
console.log('Chat history:', history);

const userInfo = await protocol.request('getUserInfo', { userId: 42 });
console.log('User info:', userInfo);
} catch (error) {
console.error('Request failed:', error.message);
}
};

Close Codes and Reasons

WebSocket connections can be closed by either side using socket.close(). The close mechanism includes a numeric code and an optional text reason that explain why the connection was closed.

Closing a Connection

// Close with default code (1005 - no status code)
socket.close();

// Close with a specific code
socket.close(1000); // Normal closure

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

// Close with a custom application code
socket.close(4000, 'Session expired');

Standard Close Codes

The WebSocket specification defines several standard close codes:

CodeNameMeaning
1000Normal ClosureConnection closed cleanly, purpose fulfilled
1001Going AwayEndpoint is going away (page navigation, server shutdown)
1002Protocol ErrorConnection closed due to a protocol error
1003Unsupported DataReceived data type that cannot be accepted (e.g., text-only endpoint received binary)
1005No Status ReceivedNo close code was provided (should not be set by applications)
1006Abnormal ClosureConnection was lost without a close frame (network failure, crash)
1007Invalid Payload DataReceived data that was not consistent with the message type (e.g., invalid UTF-8 in text)
1008Policy ViolationReceived a message that violates a policy
1009Message Too BigReceived a message that is too large to process
1010Mandatory ExtensionClient expected a server extension that was not negotiated
1011Internal ErrorServer encountered an unexpected condition
1012Service RestartServer is restarting
1013Try Again LaterServer is temporarily unavailable
1014Bad GatewayServer acting as gateway received an invalid response
1015TLS HandshakeConnection closed due to TLS handshake failure (should not be set by applications)

Custom Close Codes

Codes in the range 4000-4999 are reserved for application use. You can define your own codes for application-specific close reasons:

// Define application-specific close codes
const CLOSE_CODES = {
SESSION_EXPIRED: 4000,
DUPLICATE_CONNECTION: 4001,
INVALID_AUTH: 4002,
RATE_LIMITED: 4003,
MAINTENANCE: 4004,
KICKED: 4005
};

// Server closes with custom code
// (server-side code, for reference)
// ws.close(4001, 'Another session was opened');

// Client handles custom codes
socket.onclose = (event) => {
switch (event.code) {
case 1000:
console.log('Normal close');
break;
case 1001:
console.log('Server going away');
scheduleReconnect();
break;
case CLOSE_CODES.SESSION_EXPIRED:
console.log('Session expired');
redirectToLogin();
break;
case CLOSE_CODES.DUPLICATE_CONNECTION:
console.log('Opened in another tab');
showDuplicateSessionMessage();
break;
case CLOSE_CODES.INVALID_AUTH:
console.log('Authentication failed');
redirectToLogin();
break;
case CLOSE_CODES.RATE_LIMITED:
console.log('Rate limited');
reconnectAfterDelay(30000);
break;
case CLOSE_CODES.MAINTENANCE:
console.log('Server maintenance:', event.reason);
showMaintenanceMessage(event.reason);
break;
case 1006:
console.log('Connection lost unexpectedly');
scheduleReconnect();
break;
default:
console.log(`Closed with code ${event.code}: ${event.reason}`);
scheduleReconnect();
}
};
note

The reason string is limited to 123 bytes (not characters). Keep close reasons short. If you need to convey more information, send a regular message before closing the connection.

// WRONG: Reason too long
socket.close(4000, 'A'.repeat(200)); // Truncated or may cause an error

// CORRECT: Keep it short
socket.close(4000, 'Session expired');

Also, codes in the ranges 0-999 and 1004, 1005, 1006, 1015 cannot be used in socket.close(). They are reserved for the protocol and are only reported, never set manually.

Clean vs. Unclean Close

The wasClean property on the close event tells you whether the close followed the proper WebSocket closing handshake:

socket.onclose = (event) => {
if (event.wasClean) {
// Both sides exchanged close frames properly
// This means either:
// - Your code called socket.close()
// - The server initiated a clean close
console.log('Clean shutdown');
} else {
// The connection was lost without a proper close handshake
// Common causes:
// - Network cable unplugged
// - Server process crashed
// - Mobile device lost signal
// - Browser tab was killed
console.log('Connection lost (unclean)');
// Usually code 1006
}
};

Building a Robust WebSocket Client

Production WebSocket applications need automatic reconnection, heartbeat/ping-pong for connection health monitoring, and clean state management.

Reconnecting WebSocket

The browser's WebSocket API does not reconnect automatically. You must implement this yourself:

class ReconnectingWebSocket {
constructor(url, options = {}) {
this.url = url;
this.protocols = options.protocols || [];
this.socket = null;

// Reconnection settings
this.reconnect = options.reconnect !== false;
this.reconnectDelay = options.reconnectDelay || 1000;
this.maxReconnectDelay = options.maxReconnectDelay || 30000;
this.currentDelay = this.reconnectDelay;
this.reconnectAttempts = 0;
this.maxReconnectAttempts = options.maxReconnectAttempts || Infinity;

// State
this.intentionallyClosed = false;
this.messageQueue = [];

// Event handlers
this.onopen = null;
this.onmessage = null;
this.onclose = null;
this.onerror = null;
this.onreconnecting = null;

this.connect();
}

connect() {
try {
this.socket = new WebSocket(this.url, this.protocols);
} catch (error) {
this.scheduleReconnect();
return;
}

this.socket.binaryType = 'arraybuffer';

this.socket.onopen = (event) => {
console.log('WebSocket connected');
this.currentDelay = this.reconnectDelay; // Reset backoff
this.reconnectAttempts = 0;

// Flush queued messages
while (this.messageQueue.length > 0) {
const msg = this.messageQueue.shift();
this.socket.send(msg);
}

if (this.onopen) this.onopen(event);
};

this.socket.onmessage = (event) => {
if (this.onmessage) this.onmessage(event);
};

this.socket.onerror = (event) => {
if (this.onerror) this.onerror(event);
};

this.socket.onclose = (event) => {
if (this.onclose) this.onclose(event);

if (!this.intentionallyClosed && this.reconnect) {
this.scheduleReconnect();
}
};
}

scheduleReconnect() {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.error('Max reconnection attempts reached');
return;
}

this.reconnectAttempts++;

const jitter = Math.random() * 1000;
const delay = this.currentDelay + jitter;

console.log(
`Reconnecting in ${(delay / 1000).toFixed(1)}s ` +
`(attempt ${this.reconnectAttempts})`
);

if (this.onreconnecting) {
this.onreconnecting({
attempt: this.reconnectAttempts,
delay
});
}

setTimeout(() => {
if (this.intentionallyClosed) return;
this.currentDelay = Math.min(this.currentDelay * 2, this.maxReconnectDelay);
this.connect();
}, delay);
}

send(data) {
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
this.socket.send(data);
} else {
// Queue messages while disconnected
this.messageQueue.push(data);
}
}

sendJSON(data) {
this.send(JSON.stringify(data));
}

close(code = 1000, reason = '') {
this.intentionallyClosed = true;
this.reconnect = false;
if (this.socket) {
this.socket.close(code, reason);
}
}

get readyState() {
return this.socket ? this.socket.readyState : WebSocket.CLOSED;
}

get isConnected() {
return this.socket && this.socket.readyState === WebSocket.OPEN;
}
}

Usage:

const ws = new ReconnectingWebSocket('wss://api.example.com/ws', {
reconnectDelay: 1000,
maxReconnectDelay: 30000,
maxReconnectAttempts: 50
});

ws.onopen = () => {
console.log('Connected!');
statusIndicator.textContent = 'Online';
statusIndicator.style.color = 'green';
};

ws.onmessage = (event) => {
const data = JSON.parse(event.data);
handleServerMessage(data);
};

ws.onclose = (event) => {
statusIndicator.textContent = 'Offline';
statusIndicator.style.color = 'red';
};

ws.onreconnecting = ({ attempt, delay }) => {
statusIndicator.textContent = `Reconnecting (attempt ${attempt})...`;
statusIndicator.style.color = 'orange';
};

// Messages sent while disconnected are queued and sent on reconnect
ws.sendJSON({ type: 'subscribe', channel: 'notifications' });

// Clean shutdown when done
ws.close(1000, 'User logged out');

Heartbeat / Ping-Pong

TCP connections can silently die without either side knowing (e.g., a NAT router drops the mapping, or a mobile device switches from WiFi to cellular). Heartbeat messages detect dead connections by periodically sending a small message and expecting a response:

class HeartbeatWebSocket extends ReconnectingWebSocket {
constructor(url, options = {}) {
super(url, options);
this.heartbeatInterval = options.heartbeatInterval || 30000; // 30s
this.heartbeatTimeout = options.heartbeatTimeout || 10000; // 10s
this.heartbeatTimer = null;
this.heartbeatTimeoutTimer = null;

const originalOnOpen = this.onopen;
this.onopen = (event) => {
this.startHeartbeat();
if (originalOnOpen) originalOnOpen(event);
};

const originalOnClose = this.onclose;
this.onclose = (event) => {
this.stopHeartbeat();
if (originalOnClose) originalOnClose(event);
};

const originalOnMessage = this.onmessage;
this.onmessage = (event) => {
// Any message from the server means the connection is alive
this.resetHeartbeatTimeout();

// Handle pong responses
try {
const data = JSON.parse(event.data);
if (data.type === 'pong') {
return; // Don't pass heartbeat messages to the application
}
} catch {
// Not JSON, that's fine
}

if (originalOnMessage) originalOnMessage(event);
};
}

startHeartbeat() {
this.stopHeartbeat();

this.heartbeatTimer = setInterval(() => {
if (this.isConnected) {
this.send(JSON.stringify({ type: 'ping' }));

// If no response within timeout, consider connection dead
this.heartbeatTimeoutTimer = setTimeout(() => {
console.warn('Heartbeat timeout - closing connection');
this.socket.close(4000, 'Heartbeat timeout');
}, this.heartbeatTimeout);
}
}, this.heartbeatInterval);
}

resetHeartbeatTimeout() {
if (this.heartbeatTimeoutTimer) {
clearTimeout(this.heartbeatTimeoutTimer);
this.heartbeatTimeoutTimer = null;
}
}

stopHeartbeat() {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
}
this.resetHeartbeatTimeout();
}
}
note

The WebSocket protocol has built-in ping/pong frames at the protocol level. The server can send a Ping frame, and the browser automatically responds with a Pong frame. However, the browser's WebSocket API does not expose these frames to JavaScript. You cannot send protocol-level pings from the browser, and you cannot listen for them. This is why application-level heartbeats (using regular messages with type: 'ping') are necessary for client-initiated health checks.

Complete Real-Time Application Example

Here is a practical chat application demonstrating all the patterns:

class ChatApp {
constructor(container) {
this.container = container;
this.username = null;
this.ws = null;

this.buildUI();
}

buildUI() {
this.container.innerHTML = `
<div class="status" id="status">Disconnected</div>
<div class="messages" id="messages"></div>
<form class="input-form" id="chatForm">
<input type="text" id="nameInput" placeholder="Name" required style="width: 100px;">
<input type="text" id="msgInput" placeholder="Message..." required style="flex:1;" disabled>
<button type="submit" id="sendBtn" disabled>Send</button>
</form>
`;

this.statusEl = this.container.querySelector('#status');
this.messagesEl = this.container.querySelector('#messages');
this.nameInput = this.container.querySelector('#nameInput');
this.msgInput = this.container.querySelector('#msgInput');
this.sendBtn = this.container.querySelector('#sendBtn');
this.chatForm = this.container.querySelector('#chatForm');

this.nameInput.addEventListener('input', () => {
const hasName = this.nameInput.value.trim().length > 0;
this.msgInput.disabled = !hasName || !this.ws?.isConnected;
this.sendBtn.disabled = !hasName || !this.ws?.isConnected;
});

this.chatForm.addEventListener('submit', (e) => {
e.preventDefault();
this.sendMessage();
});
}

connect(url) {
this.ws = new ReconnectingWebSocket(url, {
reconnectDelay: 1000,
maxReconnectDelay: 15000
});

this.ws.onopen = () => {
this.setStatus('connected', 'Connected');
this.enableInput();

// Authenticate or join
if (this.username) {
this.ws.sendJSON({
type: 'join',
username: this.username
});
}
};

this.ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
this.handleMessage(msg);
};

this.ws.onclose = (event) => {
this.setStatus('disconnected', `Disconnected (${event.code})`);
this.disableInput();
};

this.ws.onreconnecting = ({ attempt }) => {
this.setStatus('connecting', `Reconnecting... (attempt ${attempt})`);
};
}

handleMessage(msg) {
switch (msg.type) {
case 'chat':
this.displayMessage(msg.username, msg.text, msg.timestamp);
break;

case 'system':
this.displaySystemMessage(msg.text);
break;

case 'userList':
this.updateUserCount(msg.count);
break;

case 'history':
msg.messages.forEach(m => {
this.displayMessage(m.username, m.text, m.timestamp);
});
break;

case 'error':
this.displaySystemMessage(`Error: ${msg.text}`);
break;
}
}

sendMessage() {
const name = this.nameInput.value.trim();
const text = this.msgInput.value.trim();
if (!name || !text) return;

this.username = name;

this.ws.sendJSON({
type: 'chat',
username: name,
text: text
});

this.msgInput.value = '';
this.msgInput.focus();
}

displayMessage(username, text, timestamp) {
const time = new Date(timestamp || Date.now()).toLocaleTimeString();
const el = document.createElement('div');
el.className = 'message';
el.innerHTML = `
<strong>${this.escapeHtml(username)}</strong>
<span>${this.escapeHtml(text)}</span>
<small>${time}</small>
`;
this.messagesEl.appendChild(el);
this.messagesEl.scrollTop = this.messagesEl.scrollHeight;
}

displaySystemMessage(text) {
const el = document.createElement('div');
el.className = 'system-message';
el.textContent = text;
this.messagesEl.appendChild(el);
this.messagesEl.scrollTop = this.messagesEl.scrollHeight;
}

updateUserCount(count) {
this.statusEl.textContent += ` (${count} users)`;
}

setStatus(className, text) {
this.statusEl.className = `status ${className}`;
this.statusEl.textContent = text;
}

enableInput() {
if (this.nameInput.value.trim()) {
this.msgInput.disabled = false;
this.sendBtn.disabled = false;
}
}

disableInput() {
this.msgInput.disabled = true;
this.sendBtn.disabled = true;
}

escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}

disconnect() {
if (this.ws) {
this.ws.close(1000, 'User left');
}
}
}

// Usage
const app = new ChatApp(document.getElementById('app'));
app.connect('wss://chat.example.com/ws');

// Clean up on page unload
window.addEventListener('beforeunload', () => {
app.disconnect();
});

Common Patterns and Best Practices

Authentication

WebSocket connections cannot send custom headers during the handshake (the browser API does not support it). There are several common approaches to authentication:

// Approach 1: Token in query string
// Simple but token appears in server logs and URL history
const socket = new WebSocket(`wss://api.example.com/ws?token=${authToken}`);

// Approach 2: Authenticate after connection (most common)
const socket = new WebSocket('wss://api.example.com/ws');

socket.onopen = () => {
// Send auth message as the first thing
socket.send(JSON.stringify({
type: 'auth',
token: authToken
}));
};

socket.onmessage = (event) => {
const msg = JSON.parse(event.data);

if (msg.type === 'auth_success') {
console.log('Authenticated!');
// Now safe to send other messages
} else if (msg.type === 'auth_failed') {
console.error('Auth failed:', msg.reason);
socket.close(4002, 'Authentication failed');
}
};

// Approach 3: Cookie-based (if same domain)
// Cookies are sent automatically during the WebSocket handshake
// No extra code needed on the client side
// Server reads the session cookie from the upgrade request
const socket = new WebSocket('wss://same-domain.com/ws');

Subscribing to Channels/Topics

Many real-time applications use a publish-subscribe pattern:

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

socket.onopen = () => {
// Subscribe to specific channels
socket.send(JSON.stringify({
type: 'subscribe',
channels: ['notifications', 'stock-prices', 'chat-general']
}));
};

socket.onmessage = (event) => {
const msg = JSON.parse(event.data);

// Messages include which channel they came from
switch (msg.channel) {
case 'notifications':
showNotification(msg.data);
break;
case 'stock-prices':
updatePriceTicker(msg.data);
break;
case 'chat-general':
appendChatMessage(msg.data);
break;
}
};

// Unsubscribe from a channel
function unsubscribe(channel) {
socket.send(JSON.stringify({
type: 'unsubscribe',
channels: [channel]
}));
}

Handling Page Visibility

When a browser tab is hidden, you might want to reduce WebSocket activity to save resources:

document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
// Tab is hidden - reduce activity
ws.sendJSON({ type: 'setPresence', status: 'away' });

// Optionally unsubscribe from high-frequency updates
ws.sendJSON({ type: 'unsubscribe', channels: ['live-cursor-positions'] });
} else {
// Tab is visible again - resume
ws.sendJSON({ type: 'setPresence', status: 'online' });
ws.sendJSON({ type: 'subscribe', channels: ['live-cursor-positions'] });
}
});

Multiple WebSocket Connections

Sometimes you need multiple WebSocket connections to different services. Manage them with a registry:

class WebSocketManager {
constructor() {
this.connections = new Map();
}

connect(name, url, options = {}) {
if (this.connections.has(name)) {
this.disconnect(name);
}

const ws = new ReconnectingWebSocket(url, options);
this.connections.set(name, ws);
return ws;
}

get(name) {
return this.connections.get(name);
}

disconnect(name) {
const ws = this.connections.get(name);
if (ws) {
ws.close(1000, 'Manager disconnect');
this.connections.delete(name);
}
}

disconnectAll() {
for (const [name, ws] of this.connections) {
ws.close(1000, 'Manager shutdown');
}
this.connections.clear();
}
}

// Usage
const manager = new WebSocketManager();

const chatWs = manager.connect('chat', 'wss://chat.example.com/ws');
chatWs.onmessage = (event) => handleChatMessage(JSON.parse(event.data));

const dataWs = manager.connect('data', 'wss://data.example.com/ws');
dataWs.onmessage = (event) => handleDataUpdate(JSON.parse(event.data));

// Clean up
window.addEventListener('beforeunload', () => manager.disconnectAll());

Graceful Shutdown on Page Unload

Always close WebSocket connections cleanly when the page unloads:

window.addEventListener('beforeunload', () => {
if (socket && socket.readyState === WebSocket.OPEN) {
socket.close(1001, 'Page navigation');
}
});

// Also handle visibility for mobile (page may be killed without unload)
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
// On mobile, the page might be killed after this
// Send any critical pending data
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({
type: 'saveState',
data: getCurrentAppState()
}));
}
}
});

Summary

WebSocket provides a persistent, full-duplex communication channel between the browser and the server. After an initial HTTP handshake that upgrades the connection, both sides can send messages freely at any time through a single TCP connection with minimal overhead per message (2-14 bytes versus hundreds of bytes for HTTP headers).

The browser API is event-driven with four events: open (connection established, safe to send), message (data received from server in event.data), error (something went wrong), and close (connection ended, with code, reason, and wasClean properties). Data is sent with socket.send(), which accepts strings, ArrayBuffer, Blob, and typed arrays.

Connections are closed with socket.close(code, reason). Standard codes include 1000 (normal closure), 1001 (going away), and 1006 (abnormal closure). Custom application codes use the range 4000-4999.

The browser API does not provide automatic reconnection, so you must implement it yourself with exponential backoff and jitter. Application-level heartbeats (ping/pong messages) are necessary to detect silently dead connections, since the browser does not expose the protocol-level ping/pong mechanism to JavaScript.

Use WebSocket when you need true bidirectional, low-latency communication: chat, multiplayer games, collaborative editing, live trading, or any scenario where both the client and server need to push data to each other at arbitrary times. For server-to-client-only streaming, consider Server-Sent Events. For infrequent updates or maximum HTTP compatibility, long polling remains a reliable choice.