WebSocket

Most of the browser networking you’ve met so far follows one rhythm: the client asks, the server answers, the connection closes. That request/response shape is a poor fit when data needs to flow the moment it exists — a new chat message, a price tick, an opponent’s move in a game. You end up polling, and polling wastes requests and adds latency.

WebSocket, defined in RFC 6455, gives you a different rhythm: one connection that stays open, over which both sides push data as “packets” whenever they like. No fresh HTTP request per message, no waiting for the other side to ask.

That makes it a strong match for anything with continuous, low-latency traffic in both directions — online games, live trading dashboards, collaborative editors, chat.

HTTP polling — one round trip per check
req →← resreq →← resreq →← res
WebSocket — one connection, messages both ways
handshake↑ msg↓ msg↓ msg↑ msg…open…
HTTP polling versus a single persistent WebSocket connection

A simple example

Opening a connection takes one line. You create a WebSocket object with a ws:// URL — a scheme unique to this protocol:

let socket = new WebSocket("ws://example.com");

There’s an encrypted variant too, wss://. It stands to ws:// the way HTTPS stands to HTTP.

Once the socket exists, you react to what happens on it. There are four events in total:

  • open — the connection is established.
  • message — data arrived.
  • error — something went wrong.
  • close — the connection ended.

And to push data the other way, socket.send(data) does the job.

Putting it together:

let socket = new WebSocket("wss://example.com/article/websocket/demo/hello");

socket.onopen = function(e) {
  alert("[open] Connection established");
  alert("Sending to server");
  socket.send("My name is Maya");
};

socket.onmessage = function(event) {
  alert(`[message] Data received from server: ${event.data}`);
};

socket.onclose = function(event) {
  if (event.wasClean) {
    alert(`[close] Connection closed cleanly, code=${event.code} reason=${event.reason}`);
  } else {
    // e.g. server process killed or network down
    // event.code is usually 1006 in this case
    alert('[close] Connection died');
  }
};

socket.onerror = function(error) {
  alert(`[error]`);
};

For the example above there’s a tiny demo server written in Node.js. It replies with “Hello from server, Maya”, waits five seconds, then closes the connection.

So the events you observe run in order: openmessageclose.

openmessage→ wait 5s →close
The event lifecycle of the demo above

There’s no live server to connect to here, so the demo below stands in a tiny mock socket that fires those same events on a timer: open, then a message echoing your name, then close after a short pause. Press Connect and watch the handlers run in order. Edit the code and re-run to change what each handler logs.

interactiveWatching open → message → close fire in order

That’s genuinely all you need to talk WebSocket. The rest of this article is about what’s happening underneath, and the details that matter once you go past “hello”.

Opening a websocket

Construction and connection are the same act — as soon as new WebSocket(url) runs, the browser starts connecting.

That connection begins as an ordinary HTTP request that politely asks to change protocols. Using request headers, the browser asks the server: “Do you speak WebSocket?” If the server says yes, both sides drop HTTP and continue in the WebSocket protocol, which is not HTTP at all.

BrowserServerGET /chat · Upgrade: websocket101 Switching ProtocolsWebSocket frames — both directions, no more HTTP
The upgrade handshake: an HTTP request that switches the connection to WebSocket

Here are the request headers the browser sends for new WebSocket("wss://example.com/chat"):

GET /chat
Host: example.com
Origin: https://example.com
Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Key: Iv8io/9s+lYFgZWcXczP8Q==
Sec-WebSocket-Version: 13
  • Origin — the origin of the page opening the socket, e.g. https://example.com. WebSocket is cross-origin by design: there are no special CORS-style restrictions and no preflight. Old servers can’t handle WebSocket at all, so there’s no compatibility risk in allowing it. The Origin header still matters, because it lets the server decide whether it wants to talk to this particular website.
  • Connection: Upgrade — the client is asking to switch protocols.
  • Upgrade: websocket — the protocol it wants to switch to.
  • Sec-WebSocket-Key — a random key the browser generates. It lets the server prove it actually understands WebSocket rather than blindly accepting. Being random, it also stops proxies from caching any of the follow-up traffic.
  • Sec-WebSocket-Version — the protocol version. 13 is current.

If the server agrees to switch, it answers with status 101:

101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: hsBlbuDTkk24srzEOTBUlZAlC2g=

Sec-WebSocket-Accept is the Sec-WebSocket-Key from the request, transformed through a fixed algorithm defined in the spec. When the browser sees the value it expects, it knows the server genuinely implements WebSocket — not some unrelated service that happened to return 101.

After that, everything travels as WebSocket frames (their structure comes up shortly). None of it is HTTP anymore.

Extensions and subprotocols

The handshake can carry two more headers: Sec-WebSocket-Extensions and Sec-WebSocket-Protocol. They negotiate, respectively, how the data is transported and what shape the data itself takes.

  • Sec-WebSocket-Extensions: deflate-frame says the browser can handle compressed frames. An extension modifies how bytes move across the wire — a transport-level add-on to the base protocol. The browser sets this header automatically, listing every extension it supports.

  • Sec-WebSocket-Protocol: soap, wamp says the app wants to speak a named subprotocol — here SOAP or WAMP (“The WebSocket Application Messaging Protocol”) — rather than arbitrary bytes. Registered subprotocols live in the IANA catalogue. This header declares which data formats you intend to use.

    Unlike extensions, you set this one yourself, through the second argument to new WebSocket — an array of subprotocol names:

    let socket = new WebSocket("wss://example.com/chat", ["soap", "wamp"]);

The server replies with the subset of protocols and extensions it agrees to.

A request offering both:

GET /chat
Host: example.com
Upgrade: websocket
Connection: Upgrade
Origin: https://example.com
Sec-WebSocket-Key: Iv8io/9s+lYFgZWcXczP8Q==
Sec-WebSocket-Version: 13
Sec-WebSocket-Extensions: deflate-frame
Sec-WebSocket-Protocol: soap, wamp

And the server’s response:

101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: hsBlbuDTkk24srzEOTBUlZAlC2g=
Sec-WebSocket-Extensions: deflate-frame
Sec-WebSocket-Protocol: soap

The server accepts the deflate-frame extension, and picks only SOAP out of the two offered subprotocols. Whatever it echoes back is the agreement both sides now honor.

Data transfer

Once the connection is live, communication is a stream of frames — fragments of data that either side can send at any time. Frames come in a few kinds:

  • text frames — carry text.
  • binary data frames — carry binary payloads.
  • ping/pong frames — a keep-alive check. The server sends a ping; the browser answers with a matching pong automatically, with no code from you.
  • there’s also a connection close frame, plus a handful of other service frames.

In the browser you only ever touch text and binary frames directly. The ping/pong and close machinery is handled for you.

your code
text · binary
automatic
ping · pong · close
Frame kinds — you handle the top two; the browser handles the rest

socket.send() accepts either text or binary.

Call socket.send(body) with a string, or with binary in any of the usual forms — Blob, ArrayBuffer, a typed array, and so on. There’s nothing to configure first; hand it whatever you’ve got.

On receive, text always arrives as a string. For binary, you choose between Blob and ArrayBuffer.

That choice lives in socket.binaryType. It defaults to "blob", so binary messages show up as Blob objects.

A Blob is a high-level binary container. It plugs straight into <a>, <img>, and similar elements, which makes it a reasonable default when you mostly want to display or download the data. When you instead need to read individual bytes, switch to "arraybuffer":

socket.binaryType = "arraybuffer";
socket.onmessage = (event) => {
  // event.data is either a string (if text) or an ArrayBuffer (if binary)
};

Rate limiting

Suppose your app produces data faster than the user’s network can carry it — think spotty mobile coverage well outside a city.

You can keep calling socket.send(data) regardless. The browser won’t drop anything; it buffers the queued bytes in memory and drains them onto the wire as fast as the connection allows. The risk isn’t lost data, it’s an ever-growing buffer eating memory while the app blindly pumps more in.

socket.bufferedAmount tells you how many bytes are still sitting in that buffer, waiting to go out. Read it to decide whether the socket has caught up before you queue more:

// every 100ms, examine the socket and send more data
// only once everything queued so far has actually left
setInterval(() => {
  if (socket.bufferedAmount == 0) {
    socket.send(moreData());
  }
}, 100);
send()buffer
bufferedAmount bytes
→ (network speed) →wire
bufferedAmount is the backlog between your send() calls and the network

This pattern — checking a queue depth before adding to it — is backpressure. It keeps a fast producer from overwhelming a slow link.

The demo below models a slow wire that drains a fixed number of bytes each tick. Flood ignores backpressure and pumps data every tick regardless — watch bufferedAmount climb without limit. Backpressure only queues more once the buffer has emptied, so it stays bounded no matter how long it runs.

interactivebufferedAmount with and without backpressure

Connection close

Either side can hang up; the browser and server have equal standing here. A clean shutdown sends a connection close frame carrying a numeric code and an optional text reason.

The method:

socket.close([code], [reason]);
  • code — a WebSocket close code (optional).
  • reason — a short string explaining why (optional).

Whatever you pass shows up on the other end inside its close event:

// closing party:
socket.close(1000, "Work complete");

// the other party
socket.onclose = event => {
  // event.code === 1000
  // event.reason === "Work complete"
  // event.wasClean === true (clean close)
};

The codes you’ll meet most:

  • 1000 — normal closure, and the default when you call close() with no code.
  • 1006 — you can never set this one yourself. The browser fills it in to mean the connection dropped without a close frame ever arriving.

Others you may run into:

  • 1001 — a party is going away, e.g. the server is shutting down or the user navigated away from the page.
  • 1009 — the message was too large to process.
  • 1011 — an unexpected error on the server.
  • …and more.

The complete list is in RFC 6455, §7.4.1.

These resemble HTTP status codes but aren’t the same set. One rule to remember: codes below 1000 are reserved, and trying to use one throws an error.

Connection state

socket.readyState reports where the socket is in its lifecycle:

  • 0CONNECTING: the handshake is underway, not yet established.
  • 1OPEN: connected and ready to exchange data.
  • 2CLOSING: a close was initiated, still finishing.
  • 3CLOSED: fully closed (or the connection failed to open).
0 CONNECTING1 OPEN2 CLOSING3 CLOSED
readyState transitions over a socket's life

Chat example

Let’s build a small group chat on top of this. The browser uses the native WebSocket API; the Node.js server uses the ws module. The client side is where most of the interesting work lives, but the server stays short too.

The HTML is a <form> to submit messages and a <div> to collect the ones that come back:

<!-- message form -->
<form name="publish">
  <input type="text" name="message">
  <input type="submit" value="Send">
</form>

<!-- div with messages -->
<div id="messages"></div>

The JavaScript has three jobs:

  1. Open the connection.
  2. On form submit, send the typed message with socket.send(message).
  3. On an incoming message, add it to div#messages.
let socket = new WebSocket("wss://example.com/article/websocket/chat/ws");

// send message from the form
document.forms.publish.onsubmit = function() {
  let outgoingMessage = this.message.value;

  socket.send(outgoingMessage);
  return false;
};

// message received - show the message in div#messages
socket.onmessage = function(event) {
  let message = event.data;

  let messageElem = document.createElement('div');
  messageElem.textContent = message;
  document.getElementById('messages').prepend(messageElem);
}

Returning false from onsubmit cancels the form’s default submission, so the page never reloads — the message goes out over the open socket instead. And using textContent rather than innerHTML matters: it drops the incoming text in as plain text, so one user can’t inject markup or script into everyone else’s page.

The server side is a bit outside our scope, and you don’t have to use Node.js — every server platform has its own WebSocket support. The logic here is:

  1. Keep clients = new Set(), a set of connected sockets.
  2. For each accepted socket, clients.add(socket) and attach a message listener.
  3. On a message, walk every client and forward it to all of them (a broadcast).
  4. On close, clients.delete(socket) so you stop sending to a dead connection.
const ws = new require('ws');
const wss = new ws.Server({noServer: true});

const clients = new Set();

http.createServer((req, res) => {
  // here we only handle websocket connections
  // in real project we'd have some other code here to handle non-websocket requests
  wss.handleUpgrade(req, req.socket, Buffer.alloc(0), onSocketConnect);
});

function onSocketConnect(ws) {
  clients.add(ws);

  ws.on('message', function(message) {
    message = message.slice(0, 50); // max message length will be 50

    for(let client of clients) {
      client.send(message);
    }
  });

  ws.on('close', function() {
    clients.delete(ws);
  });
}
Client AServerclients: SetClient AClient BClient Csendbroadcast
One message in, broadcast out to every connected client

Here it is running. There’s no real server behind this page, so a mock socket plays the server’s part: it echoes each message you send straight back (the broadcast), and a second “guest” client chimes in every few seconds so you can see incoming traffic you didn’t originate. The client-side logic — onsubmit sending over the socket, onmessage prepending into div#messages with textContent — is exactly the code above.

interactiveGroup chat over a (mock) WebSocket

To run a real version locally, pair the client code above with the Node.js server: install Node.js first, then npm install ws before starting it. Swap the mock socket for new WebSocket("wss://your-server/ws") and the handlers stay identical.

Summary

WebSocket is the modern way to keep a persistent, two-way channel open between browser and server.

  • No cross-origin restrictions — the Origin header lets the server decide who to talk to.
  • Broad browser support.
  • Carries both strings and binary data.

The API surface is small.

Methods:

  • socket.send(data)
  • socket.close([code], [reason])

Events:

  • open
  • message
  • error
  • close

What WebSocket deliberately leaves out is the higher-level stuff: reconnecting after a drop, authentication, message acknowledgement, presence. You either add those yourself or reach for a client/server library that layers them on top.

A common way to fit WebSocket into an existing app is to run a separate WebSocket server alongside the main HTTP server, sharing one database. Browser traffic to wss://ws.site.com (a subdomain pointing at the WebSocket server) stays distinct from https://site.com, which keeps serving regular pages. Plenty of other integration shapes work too — this is just one that keeps the two concerns cleanly apart.