JavaScript WebSocket: Client and Node.js Server Examples

Muhaymin Bin Mehmood

Muhaymin Bin Mehmood

·
8 min read
Published Dec 16, 2024
Updated Oct 9, 2026
How to Implement WebSockets for Real-Time Communication in JavaScript Banner Image
12 min read
1,173 words
18 sections10 code blocks

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

JavaScript
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)

WebSocketHTTP / fetch()
Who can sendBoth sides, any timeClient asks, server answers
ConnectionOne long-lived connectionRequest per call (reused under the hood)
Good forChat, live updates, games, collaborationLoading data, forms, APIs, files
Caching, CDNsNoYes
Server-only push alternativeServer-Sent Events (EventSource) if only the server sendsPolling

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

Bash
npm install ws
node server.mjs

This server welcomes each client, broadcasts chat messages to everyone, rejects invalid JSON, and drops dead connections with a heartbeat:

JavaScript
// 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:
JavaScript
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:

JavaScript
// 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");
    },
  };
}
JavaScript
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

readyStateConstantMeaning
0WebSocket.CONNECTINGHandshake in progress; send() throws InvalidStateError
1WebSocket.OPENConnected; send() works
2WebSocket.CLOSINGclose() was called; send() is ignored
3WebSocket.CLOSEDClosed or failed to connect

Don't call socket.on("ping") in the browser

JavaScript
// 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 function

Browser 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:

JSX
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:
JavaScript
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

JavaScript
// 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

MistakeWhat happensFix
send() right after new WebSocket()InvalidStateError: still CONNECTINGWait for open, or queue (createSocket)
socket.on("ping") in the browserTypeError: socket.on is not a functionBrowsers answer pings automatically
Logging the ws message directlyPrints <Buffer …>raw.toString(), then JSON.parse
Reconnecting every N seconds foreverThundering herd; can't stop itBackoff + jitter; stop after close()
No heartbeat on the serverDead connections pile upping/pong + terminate()
No Origin or auth checkOther sites can use your users' sessionsAllow-list origins, authenticate
JSON.parse without try/catchOne bad message crashes the handlerWrap 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.

No comments yet. Be the first to comment!

Leave a Comment

2000 characters remaining

Related Articles

View all
Muhaymin Bin Mehmood

About 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.