WebSocket
How real-time connections work on the platform: the two root-mounted
upgrade endpoints (/ws/liveness and /ws/pubsub/{channel}), both auth
modes, the JSON message protocol, connection lifecycle and limits, and the
admin connection controls. Every example on this page was executed against
a live server.
When to reach for it: streaming pub/sub messages or liveness updates to browsers, CLIs, and services - anywhere polling an HTTP endpoint would be too slow or too chatty.
When not to: some services run their own sockets on this same layer,
following the same auth and lifecycle rules - collaborative document change
feeds belong to Live JSON
(/ws/live-json/{document_id}), entity change subscriptions to
ECS, and topic streams to
Consensus.
Concepts
- Two sockets.
/ws/livenessis the general multiplexed socket, carrying liveness entity subscriptions plus any number of in-band pub/sub subscriptions;/ws/pubsub/{channel}is bound to one channel at the handshake. - The handshake is the gate - authentication, scope checks, and (on the pub/sub socket) per-channel authorization all run before the upgrade. A denied client never gets a socket; it gets a normal enveloped HTTP error.
- Two auth modes - the
Authorization: Bearerheader, or a?token=query parameter for browserWebSocketclients that cannot set headers. Prefer the header elsewhere; query strings tend to end up in logs. - Frames are JSON with a
typefield (ping,subscribe,pubsub_message, ...). Unparseable input gets an in-banderrorframe back, not a disconnect. - Connections die with their token - a socket is torn down when the JWT that opened it expires; long-lived clients reconnect with a fresh one.
- Scopes confine sockets - a token carrying a
scopeclaim needsliveness:readfor/ws/livenessandpubsub:readfor/ws/pubsub/{channel}. Pub/sub capability tokens open the general socket but are confined to its pub/sub half.
Connecting
const ws = new WebSocket(`ws://localhost:4000/ws/liveness?token=${jwt}`);Anything that can set headers should send Authorization: Bearer instead.
A successful handshake answers 101 Switching Protocols; a missing or
invalid token answers 401 with the standard envelope (captured live):
{
"status": 401,
"msg": "WebSocket authentication failed: Missing Authorization header or token query parameter",
"error": "websocket_auth_failed"
}A token whose scope claim lacks the needed service scope is also refused
at the handshake (requires 'liveness:read' / requires 'pubsub:read');
tokens without a scope claim are unrestricted.
The message protocol
Client-sent frames: ping, subscribe / unsubscribe (liveness
entities; entity_id omitted means all), pubsub_subscribe /
pubsub_unsubscribe (channels). Server-sent: pong, subscribe echoes as
acknowledgements, liveness_update, pubsub_message, pubsub_denied,
and error. A live exchange on /ws/liveness, client frames on the left:
{"type":"ping"} -> {"type":"pong"}
{"type":"subscribe","entity_id":"sensor-1"} -> {"type":"subscribe","entity_id":"sensor-1"}
not json -> {"type":"error","message":"Invalid message format: ... Expected JSON with 'type' field."}Field-level schemas for every frame are in the WebSocket API reference.
Pub/sub over WebSocket
The dedicated socket subscribes at the handshake; the CLI wraps it:
snug pubsub listen -c u.5JY5XUIVDLNWX5WV.docs4 # blocks; CTRL+C to stop
snug pubsub publish -c u.5JY5XUIVDLNWX5WV.docs4 -p '{"greeting":"hello"}'The listener receives each publish as a pubsub_message frame (captured):
{
"type": "pubsub_message",
"channel": "u.5JY5XUIVDLNWX5WV.docs4",
"message_id": "msg_jDEGDYUFqBWp",
"payload": { "greeting": "hello" },
"publisher_id": "docs-wave",
"timestamp": 1787893459
}Channel access is capability-based (channels live in dot-separated
namespaces like u.{namespace}.*; mint a grant with snug pubsub token -
see the Pub/Sub API). The two sockets
enforce it differently, both verified live: /ws/pubsub/{channel} denies
at the handshake (403 channel_forbidden, missing 'subscribe' capability for '...') so an unauthorized subscriber never opens a socket, while an
in-band pubsub_subscribe on the general socket answers a pubsub_denied
frame with the same reason and leaves the connection open.
Sending pubsub_unsubscribe for the dedicated socket's own channel closes
that connection cleanly (close code 1000); on the general socket it just
drops the one subscription. And one confinement rule: a pub/sub capability
token may open /ws/liveness and subscribe to channels there, but its
liveness subscribe frames are refused with an error frame
(this credential is confined to pub/sub).
Connection lifecycle
- Token expiry closes the socket. Verified with a 25-second token: the server tore the connection down the moment the JWT expired.
- Keepalive is server-driven - the server pings on an interval and
drops connections that stop answering or go idle (defaults: ping every
30s, pong timeout 60s, idle cutoff 300s). App-level
{"type":"ping"}also counts as activity. - Per-user connection cap (20 concurrent sockets by default). Verified
live: the 21st connection completes the
101upgrade and is then immediately closed, so treat an instant close-after-connect as "over the cap", not a network error./ws/pubsub/{channel}opens one socket per channel, which is how the cap is usually hit. - In-band messages are rate limited per message type (subscribe, ping,
and general buckets). Verified live: a ping flood was answered with an
errorframe (Rate limit exceeded. Please wait 1 seconds before sending more ping messages.); the connection stayed open and worked again after the wait.
Admin: inspecting and pruning connections
Requires a platform admin token; non-admins get 403 FORBIDDEN.
snug websocket stats # health buckets, per-user counts, rate limit config
snug websocket cleanup # force-drop stale/unhealthy/unresponsive socketsOver HTTP, GET /admin/websockets/stats (captured live with one held
connection, trimmed):
{
"status": 200,
"msg": "OK",
"data": {
"health": { "total": 1, "healthy": 1, "unhealthy": 0, "stale": 0, "unresponsive": 0 },
"connections_by_user": { "docs-wave": 1 },
"rate_limit_stats": [
{ "endpoint": "/ws/subscribe", "max_requests": 300, "window_seconds": 60, "configured": true },
...
]
}
}POST /admin/websockets/cleanup answers {"removed": n, "remaining": m};
healthy connections are left alone (verified: cleanup removed 0 with one
healthy connection held).
Limits and configuration
Connection totals (100 global / 20 per user by default), ping and idle
timing, per-message-type rate limits, handshake admission control (excess
concurrent handshakes are rejected with close code 1013), and outbound
queue budgets are all tunable via the WEBSOCKET_* variables in the
WebSocket CONFIG reference.
Reference
- WebSocket API - the upgrade endpoints and frame schemas
- WebSocket Admin API - stats and cleanup
- Related: Pub/Sub for channels,
capabilities, and publishing;
Liveness for the heartbeat data
behind
liveness_update