A WebSocket keeps one connection open between the browser and your server, so either side can send a message at any time: chat, live notifications, dashboards, multiplayer games. In JavaScript you open one with new WebSocket(url), listen for message events, and call send(). This guide builds a Node.js server with the ws package, a browser client that reconnects on its own, and a React hook, and covers the mistakes that break real apps.
Tested with: ws 8.22 on Node.js 20.20, the client in Chromium 156 and WebKit 27.2 (Safari's engine) via Playwright, and React 19.3 under StrictMode, October 2026. We stopped and restarted the server mid-test to check reconnecting.
WebSocket in 30 Seconds
const socket = new WebSocket("wss://example.com/chat");
socket.addEventListener("open", () => {
socket.send(JSON.stringify({ type: "chat", text: "Hello!" }));
});
socket.addEventListener("message", (event) => {
console.log("Server says:", event.data);
});
socket.addEventListener("close", (event) => {
console.log("Closed", event.code, event.reason);
});- ws:// is unencrypted; use wss:// on any HTTPS page (browsers block ws:// from HTTPS pages as mixed content).
- Messages are strings or binary. Send objects as JSON and JSON.parse them on the other side.
- You can only send() once the socket is open. Before that, the browser throws InvalidStateError.
Full API: WebSocket on MDN.
WebSocket vs HTTP (fetch)
| WebSocket | HTTP / fetch() | |
|---|---|---|
| Who can send | Both sides, any time | Client asks, server answers |
| Connection | One long-lived connection | Request per call (reused under the hood) |
| Good for | Chat, live updates, games, collaboration | Loading data, forms, APIs, files |
| Caching, CDNs | No | Yes |
| Server-only push alternative | Server-Sent Events (EventSource) if only the server sends | Polling |
If the browser only needs to receive updates, Server-Sent Events are simpler. For normal requests, stay with fetch().
Step 1: A Node.js WebSocket Server
npm install ws
node server.mjsThis server welcomes each client, broadcasts chat messages to everyone, rejects invalid JSON, and drops dead connections with a heartbeat:
// npm install ws (package.json needs "type": "module", or rename to .mjs)
import { WebSocketServer, WebSocket } from "ws";
const PORT = Number(process.env.PORT) || 8080;
const wss = new WebSocketServer({ port: PORT });
function broadcast(data) {
const text = JSON.stringify(data);
for (const client of wss.clients) {
if (client.readyState === WebSocket.OPEN) client.send(text);
}
}
wss.on("connection", (ws) => {
ws.isAlive = true;
ws.on("pong", () => (ws.isAlive = true));
ws.send(JSON.stringify({ type: "welcome", online: wss.clients.size }));
ws.on("message", (raw) => {
// raw is a Buffer: convert it before parsing
let msg;
try {
msg = JSON.parse(raw.toString());
} catch {
return ws.send(JSON.stringify({ type: "error", error: "Invalid JSON" }));
}
if (msg.type === "chat" && typeof msg.text === "string") {
broadcast({ type: "chat", text: msg.text.slice(0, 500), at: Date.now() });
}
});
ws.on("error", console.error);
});
// Heartbeat: drop connections that stopped answering pings
const HEARTBEAT_MS = Number(process.env.HEARTBEAT_MS) || 30000;
const heartbeat = setInterval(() => {
for (const ws of wss.clients) {
if (!ws.isAlive) {
ws.terminate();
continue;
}
ws.isAlive = false;
ws.ping();
}
}, HEARTBEAT_MS);
wss.on("close", () => clearInterval(heartbeat));
console.log(`WebSocket server on ws://localhost:${PORT}`);- Messages arrive as a Buffer. Logging raw directly prints bytes, not text:
ws.on("message", (raw) => {
console.log(raw); // <Buffer 48 65 6c 6c 6f ...>
console.log(raw.toString()); // Hello, server!
});- Check readyState before sending to clients that may be closing.
- The heartbeat matters. A phone that loses signal doesn't send a close frame, so without pings the server keeps the dead socket forever. Every 30 s the server pings; a client that didn't answer the previous ping is terminated. In our test (with the interval set to 300 ms) a client that never answered was dropped after about 430 ms, while a normal client stayed connected. Browsers answer pings automatically.
- One interval for all clients, cleared when the server closes. Starting a new setInterval per connection and never clearing it leaks timers (see clearing timeouts and intervals).
Docs for the package: ws on GitHub.
Step 2: A Browser Client That Reconnects
The plain WebSocket never reconnects by itself. This wrapper queues messages until the socket is open, parses JSON, and retries with exponential backoff:
// A browser WebSocket that queues messages until it's open and
// reconnects with backoff after an unexpected disconnect.
export function createSocket(url, { onMessage, onStatus = () => {} } = {}) {
let ws;
let attempt = 0;
let retryTimer;
let closedByUs = false;
const queue = [];
function connect() {
onStatus("connecting");
ws = new WebSocket(url);
ws.addEventListener("open", () => {
attempt = 0;
onStatus("open");
while (queue.length) ws.send(queue.shift());
});
ws.addEventListener("message", (event) => {
let data = event.data;
try {
data = JSON.parse(data);
} catch {
// not JSON: pass the raw string through
}
onMessage?.(data);
});
ws.addEventListener("close", () => {
if (closedByUs) return onStatus("closed");
// 0.5s, 1s, 2s, 4s ... capped at 30s, plus jitter
const delay = Math.min(30000, 500 * 2 ** attempt) + Math.random() * 250;
attempt++;
onStatus("reconnecting");
retryTimer = setTimeout(connect, delay);
});
// "error" is always followed by "close", so reconnecting happens there
}
connect();
return {
send(data) {
const text = typeof data === "string" ? data : JSON.stringify(data);
if (ws.readyState === WebSocket.OPEN) ws.send(text);
else queue.push(text);
},
close() {
closedByUs = true;
clearTimeout(retryTimer);
ws.close(1000, "Client closed");
},
};
}import { createSocket } from "./socket.js";
const chat = createSocket("ws://localhost:8080", {
onStatus: (status) => console.log(status), // connecting, open, reconnecting, closed
onMessage: (msg) => {
if (msg.type === "chat") console.log(msg.text);
},
});
chat.send({ type: "chat", text: "Hi everyone" }); // safe even before "open"
// later, e.g. when the user logs out:
chat.close();What we saw in testing:
- A message sent before the connection opened was queued and delivered: the server broadcast it back once the socket opened.
- We killed the server for about 2.5 s. Chromium retried after 0.6 s, 1.7 s and 3.8 s and reconnected once the server was back; WebKit's retry stayed pending until the server came back and then connected. Messages sent after that worked in both.
- After close(), the status went to closed and the client did not reconnect, even after the server restarted.
- The browser logs "WebSocket connection failed" in the console for each failed attempt. That's expected and can't be suppressed.
Why backoff and jitter: if your server restarts with 5,000 users online, a fixed 3-second retry makes all of them reconnect at the same moment, every 3 seconds. Growing delays plus a random offset spread the load.
readyState values
| readyState | Constant | Meaning |
|---|---|---|
| 0 | WebSocket.CONNECTING | Handshake in progress; send() throws InvalidStateError |
| 1 | WebSocket.OPEN | Connected; send() works |
| 2 | WebSocket.CLOSING | close() was called; send() is ignored |
| 3 | WebSocket.CLOSED | Closed or failed to connect |
Don't call socket.on("ping") in the browser
// From many tutorials (including an older version of this one). Don't use it:
socket.on("ping", () => {
socket.pong();
});
// TypeError: socket.on is not a functionBrowser WebSockets have no on(), ping() or pong(). In both Chromium and WebKit this threw TypeError. Pings are handled by the browser; if you need an app-level heartbeat (for example to detect a dead connection from the client side), send your own { type: "ping" } message.
Step 3: Using WebSockets in React
Open the socket in an effect and close it in the cleanup, so each mounted component has exactly one connection:
import { useEffect, useRef, useState } from "react";
import { createSocket } from "./socket.js";
export function useChatSocket(url) {
const [messages, setMessages] = useState([]);
const [status, setStatus] = useState("connecting");
const socketRef = useRef(null);
useEffect(() => {
const socket = createSocket(url, {
onStatus: setStatus,
onMessage: (msg) => {
if (msg.type === "chat") setMessages((list) => [...list, msg]);
},
});
socketRef.current = socket;
return () => socket.close(); // one connection per mounted component
}, [url]);
const send = (text) => socketRef.current?.send({ type: "chat", text });
return { messages, status, send };
}- Under StrictMode React mounts, unmounts and remounts in development. The first socket is closed by the cleanup; WebKit logs "WebSocket is closed before the connection is established" for it, which is harmless. In our test the server saw exactly one connection from the component, and zero after it unmounted.
- Without the cleanup you'd get two connections in development and a leaked one every time the component unmounts. More on this in why useEffect runs twice.
- Keep the URL stable (a string, not an object built on every render), or the effect will reconnect on each render.
Security Checklist
- Use wss:// in production. It's TLS, the same as HTTPS.
- Check the Origin header. WebSockets are not covered by CORS: any website can open a socket to your server, and cookies that allow cross-site use go along with it (this attack is called Cross-Site WebSocket Hijacking). Only accept origins you trust:
import { WebSocketServer } from "ws";
const ALLOWED_ORIGINS = new Set(["https://example.com", "http://localhost:5173"]);
const wss = new WebSocketServer({ port: 8080 });
wss.on("connection", (ws, req) => {
// Browsers always send Origin; other sites can open sockets to you too
if (!ALLOWED_ORIGINS.has(req.headers.origin)) {
ws.close(1008, "Origin not allowed");
return;
}
// ...check the session cookie or a token here, then handle messages
});In our test, a connection with Origin https://evil.example was closed with code 1008, and an allowed origin stayed open. (For how CORS works with fetch, see our Fetch CORS guide.)
- Authenticate during the handshake (session cookie or a short-lived token) and check permissions on every message, not only at connect time.
- Validate and limit input: parse JSON in a try/catch, check types, cap message size (maxPayload option in ws) and rate-limit chatty clients.
- Escape output: a chat message rendered with innerHTML is an XSS hole. Use textContent or React.
WebSocket Clients in Node.js
// Node.js 22+: WebSocket is a global, same API as the browser
const socket = new WebSocket("ws://localhost:8080");
socket.addEventListener("message", (event) => console.log(event.data));
// Older Node.js: use the client from the ws package
import { WebSocket } from "ws";Node.js 20 (which we tested on) has no global WebSocket without a flag, so use the ws client there.
Scaling Beyond One Server
- Each server only knows its own connected clients. With several instances, broadcast through a shared channel such as Redis pub/sub so every instance forwards the message.
- Load balancers must allow the HTTP Upgrade and long-lived connections; raise idle timeouts above your heartbeat interval.
- Serverless functions can't hold WebSocket connections open. On platforms like Vercel, use a separate long-running server or a hosted service.
- Background tabs: browsers throttle timers there, but open WebSockets keep receiving. If you want to pause updates when the page is hidden, see detecting tab changes.
Common Mistakes
| Mistake | What happens | Fix |
|---|---|---|
| send() right after new WebSocket() | InvalidStateError: still CONNECTING | Wait for open, or queue (createSocket) |
| socket.on("ping") in the browser | TypeError: socket.on is not a function | Browsers answer pings automatically |
| Logging the ws message directly | Prints <Buffer …> | raw.toString(), then JSON.parse |
| Reconnecting every N seconds forever | Thundering herd; can't stop it | Backoff + jitter; stop after close() |
| No heartbeat on the server | Dead connections pile up | ping/pong + terminate() |
| No Origin or auth check | Other sites can use your users' sessions | Allow-list origins, authenticate |
| JSON.parse without try/catch | One bad message crashes the handler | Wrap it; see our Unexpected token guide |
If JSON.parse errors confuse you, read Unexpected token errors in JavaScript and JSON.
FAQs
How do I use WebSocket in JavaScript?
Create new WebSocket("wss://your-server"), wait for the open event, send with socket.send(), and read event.data in a message listener. You also need a WebSocket server, for example Node.js with the ws package.
Does WebSocket reconnect automatically?
No. When the connection drops you get a close event and must create a new WebSocket yourself, ideally with backoff like the createSocket helper above.
What's the difference between ws:// and wss://?
wss:// is WebSocket over TLS, like HTTPS. Use it in production; HTTPS pages can't open ws:// connections.
Do WebSockets work with CORS?
CORS doesn't apply to WebSockets. The browser sends an Origin header and it's your server's job to check it.
When should I use Server-Sent Events instead?
When only the server sends updates (notifications, live scores, logs). EventSource works over normal HTTP and reconnects automatically. Use WebSockets when the client also sends messages often.
How many WebSocket connections can a server handle?
Thousands per Node.js process is common; the limits are memory per connection, file descriptors and how much work each message takes. Beyond one process, use several instances with a shared pub/sub channel.
Related Articles
View allAbout Muhaymin Bin Mehmood
Front-end Developer skilled in the MERN stack, experienced in web and mobile development. Proficient in React.js, Node.js, and Express.js, with a focus on client interactions, sales support, and high-performance applications.




