Skip to content

WebSocket

Try the interactive example ↓

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.

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/liveness is 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: Bearer header, or a ?token= query parameter for browser WebSocket clients that cannot set headers. Prefer the header elsewhere; query strings tend to end up in logs.
  • Frames are JSON with a type field (ping, subscribe, pubsub_message, ...). On /ws/liveness unparseable input gets an in-band error frame back, not a disconnect; /ws/pubsub/{channel} ignores it silently.
  • 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 scope claim needs liveness:read for /ws/liveness and pubsub:read for /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; on /ws/liveness a missing or invalid token answers 401 with the standard envelope (captured live), while /ws/pubsub/{channel} answers 401 UNAUTHORIZED ("Missing authentication token") and 403 when the token's scope does not cover the channel:

{
  "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: /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). The 21st connection completes the 101 upgrade 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). A ping flood was answered with an error frame (Rate limit exceeded: wait 1s 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 sockets

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

Reference

Interactive example

Connect a client to a stream of live events. Subscribe to the updates it needs and keep track of its connection as it comes and goes.

An illustrative example that runs in your browser. No account or API calls required.