Skip to main content

Your first WebSocket connection

Acme's warehouse dashboard runs in a browser and needs to see orders.> as orders arrive. This page gets there in two steps: open a WebSocket listener, then connect to it — first with the nats CLI, because it proves the listener works without any HTML, then with the browser page itself.

Open the listener

The websocket {} block takes its own host and port, separate from the client port on 4222:

# ws-dev.conf — one server, two listeners
listen: 127.0.0.1:4222

websocket {
listen: 127.0.0.1:8080
no_tls: true
}

no_tls: true is what makes this run on a laptop. The listener requires TLS otherwise, and leaving out both a tls {} block and this flag stops the server before it starts:

nats-server: websocket requires TLS configuration

TLS and proxies sets up the production shape. Until then everything here is plaintext on localhost.

Start it:

nats-server -c ws-dev.conf

Two log lines confirm the state you're in:

[INF] Listening for websocket clients on ws://127.0.0.1:8080
[WRN] Websocket not configured with TLS. DO NOT USE IN PRODUCTION!

The warning is accurate. A WebSocket client sending a bearer token over ws:// sends it in the clear.

Set the port, or there's no listener

There's no default port. Write the block without one and the server starts, logs nothing about WebSocket, and listens on 4222 only:

websocket {
no_tls: true
}
[INF] Listening for client connections on 127.0.0.1:4222

No error, no warning, no listener. If a WebSocket client can't connect and the log says nothing about WebSocket at all, this is why. Set port, or listen, which sets host and port together.

Connect the CLI over ws

The nats CLI is built on the Go client, which accepts ws://. Subscribe through the WebSocket listener:

nats -s ws://127.0.0.1:8080 sub "orders.>"
16:15:11 Subscribing on orders.>

In a second terminal, publish over the ordinary client port:

nats -s nats://127.0.0.1:4222 pub orders.new "ord_8w2k"

The subscriber receives it:

[#1] Received on "orders.new"
ord_8w2k

One publisher on 4222, one subscriber on 8080, same subject, same server. Neither side knows or cares which transport the other used. There's no bridging step and no separate subject space — the WebSocket listener is another door into the same server.

What the transport changes

Only the URL. Every client that supports WebSocket takes the same server list it always did, with a different scheme:

ClientServer URL
nats CLInats -s ws://127.0.0.1:8080
Gonats.Connect("ws://127.0.0.1:8080")
JavaScript/TypeScriptwsconnect({ servers: "ws://127.0.0.1:8080" })
Pythonnats.client.connect("ws://127.0.0.1:8080") — needs pip install nats-core[websocket]
JavaNats.connect("ws://127.0.0.1:8080")
Rustasync_nats::connect("ws://127.0.0.1:8080")
C#/.NETnew NatsOpts { Url = "ws://127.0.0.1:8080" }

Python is the one that needs more than a URL. The nats-core client keeps its WebSocket transport behind an optional extra, so plain pip install nats-core can't open a ws:// connection — install nats-core[websocket]. It's the new Python client and needs Python 3.13 or later.

Use wss:// where the listener holds a certificate. Subjects, queue groups, request-reply, JetStream, and headers all behave as they do on a nats:// connection, because the protocol above the transport is the same protocol.

Connect the browser

Now the dashboard. This is one file, no build step — save it as dashboard.html and open it:

<!doctype html>
<title>Acme orders</title>
<ul id="orders"></ul>

<script type="module">
import { wsconnect } from "https://esm.sh/@nats-io/nats-core";

const nc = await wsconnect({ servers: "ws://127.0.0.1:8080" });
const list = document.getElementById("orders");

for await (const m of nc.subscribe("orders.>")) {
const li = document.createElement("li");
li.textContent = `${m.subject}${m.string()}`;
list.append(li);
}
</script>

Publish again from the CLI:

nats -s nats://127.0.0.1:4222 pub orders.new "ord_2zr9"

The line appears on the page. The subscription is an ordinary NATS subscription, and orders.> is an ordinary wildcard.

Loading the file straight from disk works because the server accepts any origin by default. That stops being true the moment you configure origin checking, which is where Browsers and origins starts.

Always give nats.js an explicit port

wsconnect() fills in a port you leave out, and the value it picks is never 4222:

What you passWhat it connects to
ws://host:8080ws://host:8080
ws://hostws://host:80
wss://hostwss://host:443
hostwss://host:443
nats://host:4222ws://host:4222

A bare hostname is treated as TLS on 443. Dropping the port from a ws:// URL gets you port 80. Neither is likely to be your listener, and the failure looks like the server being down. The last row is a trap of its own: the port survives but the scheme doesn't, so the client sends a WebSocket handshake to the plain client port, which won't answer it. Write the scheme and the port every time.

Binary frames, and why a frame isn't a message

Two protocol details matter, one for everyone and one only if you're writing a client.

NATS uses WebSocket data frames in binary. The server always sends binary, and clients are expected to do the same.

If you're implementing a client library: a WebSocket frame is not guaranteed to contain a whole NATS protocol message, and generally won't. One frame can carry part of a MSG, or several messages at once. Feed frame payloads to a parser that handles partial protocol rather than assuming frame boundaries mean anything. The client protocol reference describes what that parser has to accept.

Pitfalls

Pointing a client at the wrong listener. nats:// to 8080 and ws:// to 4222 both fail, and neither failure names the real problem. The listeners are separate: WebSocket clients use the websocket {} port, everything else uses the client port.

A websocket {} block with no port. The server starts and the listener doesn't exist. There is no default. Check the log for Listening for websocket clients — if it's absent, the block set no port.

A websocket {} block with neither tls {} nor no_tls: true. The listener requires TLS by default, so the server exits at startup with websocket requires TLS configuration.

A client URL without an explicit port. nats.js fills one in and never picks 4222 — a bare host becomes wss://host:443, and ws://host becomes port 80. Write the port every time.

Shipping no_tls: true. It's a development flag. The DO NOT USE IN PRODUCTION! warning is the only thing the server will say about it, and it says it once at startup.

A FIPS-140 build made with an older Go toolchain. The WebSocket handshake computes a SHA-1 over Sec-WebSocket-Key, which a FIPS build from Go 1.25 or earlier won't permit, so the server refuses the whole listener:

websocket: cannot be used in FIPS-140 mode when built with this Go
version, use Go 1.26 or later

The same build rejects ws:// and wss:// leafnode remotes for the same reason. Built with Go 1.26 or later, WebSocket works under FIPS normally — SHA-1 here only derives the handshake key and isn't protecting anything.

Where you are

You have one server with two listeners:

  • NATS clients on 4222, WebSocket clients on 8080
  • nats -s ws://127.0.0.1:8080 sub "orders.>" receiving what a nats:// publisher sends
  • a browser page showing orders.> live
  • no_tls: true, which is fine on a laptop and wrong everywhere else

Keep the server and dashboard.html; the next two pages build on both.

What's next

The dashboard works because the server currently accepts a connection from any origin. A page served from anywhere — including a page you didn't write — can connect to it. Browsers and origins covers what to do about that, and how a browser proves who it is without a credentials file.

See also