🔌 WebSocket¶
A WebSocket connection opens a bidirectional persistent channel between the browser and server over TCP, after an HTTP handshake. Unlike normal HTTP — request/response — WS leaves the socket open and both ends can send messages whenever they want, without headers for each one.
Ideal for:
- Chat / real-time messaging.
- Push notifications (order status, alerts).
- Dashboards that update instantly (without polling).
- Live collaboration (shared cursors, simultaneous editing).
- Games and low-latency simulations.
Browser HIX
│ │
│── GET /ws + Upgrade: websocket ──────────>│ handshake
│ │
│<── HTTP 101 Switching Protocols ──────────┤
│ │
│── frame "hello" ──────────────────────────>│ bOnWsMessage
│ │
│<── frame "hello too" ──────────────────────┤ oConn:Send(...)
│ │
│── ping ───────────────────────────────────>│
│<── pong ──────────────────────────────────┤
│ │
│── frame CLOSE ────────────────────────────>│ bOnWsClose
When NOT to use it¶
| Case | Better option |
|---|---|
| Only server pushes data (no client response) | SSE — simpler |
| Sporadic events (every minute/hour) | Long polling or webhook |
| Normal request/response communication | Standard HTTP |
| Client without WebSocket support (rare in 2026) | SSE as fallback |
WS opens a permanent socket per connected client. 10,000 clients = 10,000 open sockets. Size
pool_wsto support them.
Setup¶
Dedicated pool in hix.json¶
workers: maximum concurrent WS connections.
ping_interval_s: seconds between pings server → client.
ping_timeout_s: seconds for the client to respond to ping.
Each active WS connection blocks a worker until it closes. If you expect 500 concurrent clients, bump
workersto 500.
Callbacks on the server¶
PROCEDURE Main()
LOCAL oSrv := THixServer():New()
oSrv:bOnWsConnect := {|oConn|
l( "WS connect: " + oConn:cIP )
}
oSrv:bOnWsMessage := {|oConn, cMsg, nOpcode|
// nOpcode: 1=text, 2=binary, 8=close, 9=ping, 10=pong
oConn:Send( "Echo: " + cMsg )
}
oSrv:bOnWsClose := {|oConn|
l( "WS close: " + oConn:cIP )
}
oSrv:Start()
IF oSrv:hThread != NIL
hb_threadJoin( oSrv:hThread )
ENDIF
RETURN
There's no specific URL for WS — the handshake is detected by
Upgrade: websocketheaders, not by path. Every WS connection that reaches the server goes through these three callbacks.
THixWsConn — the active connection¶
Each callback receives the oConn object with the current connection:
| Property / method | Type | Meaning |
|---|---|---|
oConn:cIP |
string | Client IP |
oConn:lClosed |
logical | .T. if already closed |
oConn:Send( cText ) |
method | Sends text frame (opcode 1) |
oConn:SendBinary( cData ) |
method | Sends binary frame (opcode 2) |
oConn:Close() |
method | Closes the connection (opcode 8) |
Typical pattern¶
oSrv:bOnWsMessage := {|oConn, cMsg, nOpcode|
LOCAL hData
IF nOpcode == 8 // CLOSE — client closes
oConn:Close()
RETURN NIL
ENDIF
IF nOpcode == 9 // PING — HIX responds pong automatically
RETURN NIL
ENDIF
IF nOpcode == 1 // text
hData := hb_jsonDecode( cMsg ) // try to parse as JSON
_ProcessClientMessage( oConn, hData )
ENDIF
}
JavaScript client¶
const ws = new WebSocket("wss://app.com/ws")
ws.onopen = () => console.log("Connected")
ws.onmessage = (e) => {
const msg = JSON.parse(e.data)
console.log("Received:", msg)
}
ws.onerror = (e) => console.error("WS error", e)
ws.onclose = () => console.log("Closed")
// Send message
ws.send( JSON.stringify({ action: "subscribe", channel: "orders" }) )
Always use
wss://when serving HTTPS. Mixingws://withhttps://causes mixed-content and the browser blocks it.
Broadcast to multiple clients¶
HIX doesn't include a native broadcaster — keep it manually in a shared array (with mutex):
STATIC s_aConnections := {}
STATIC s_oMutex := NIL
INIT PROCEDURE _InitWsRegistry()
s_oMutex := hb_mutexCreate()
RETURN
PROCEDURE WsRegister( oConn )
hb_mutexLock( s_oMutex )
AAdd( s_aConnections, oConn )
hb_mutexUnlock( s_oMutex )
RETURN
PROCEDURE WsUnregister( oConn )
LOCAL n
hb_mutexLock( s_oMutex )
n := AScan( s_aConnections, {|o| o == oConn } )
IF n > 0
hb_ADel( s_aConnections, n, .T. )
ENDIF
hb_mutexUnlock( s_oMutex )
RETURN
PROCEDURE WsBroadcast( cMsg )
LOCAL oConn
hb_mutexLock( s_oMutex )
FOR EACH oConn IN s_aConnections
IF ! oConn:lClosed
oConn:Send( cMsg )
ENDIF
NEXT
hb_mutexUnlock( s_oMutex )
RETURN
And in the callbacks:
oSrv:bOnWsConnect := {|oConn| WsRegister( oConn ) }
oSrv:bOnWsClose := {|oConn| WsUnregister( oConn ) }
// From any HTTP action:
WsBroadcast( hb_jsonEncode( { "type" => "new_order", "id" => nId } ) )
Ping / pong¶
HIX sends an automatic ping every ping_interval_s seconds. If the
client doesn't respond with pong within ping_timeout_s seconds, HIX closes the
connection.
Used for:
- Detecting disconnected clients (router down, mobile in suspend).
- Keeping the connection alive against proxies that cut idle sockets.
30s/10s are sound values. If you go through Cloudflare or other proxies that cut at 100s idle, use lower
ping_interval_s(15–20s).
Authentication¶
The WS handshake carries normal HTTP headers (cookies included) —
you can read the session cookie in bOnWsConnect:
oSrv:bOnWsConnect := {|oConn|
// At this point HIX_GetRequest() is not available —
// capture the data during handshake if you need it.
l( "WS connect from " + oConn:cIP )
}
For auth, the usual approach is pass a token as query string:
And validate it in bOnWsConnect (read the original request): if it's not
valid, oConn:Close() before registering.
Useful patterns¶
Subscriptions by channel¶
// State per connection — use connection hash as key
STATIC s_hSubs := {=>} // { connId => { "orders", "alerts" } }
oSrv:bOnWsMessage := {|oConn, cMsg, nOpcode|
LOCAL hMsg, aChannels
IF nOpcode != 1 ; RETURN NIL ; ENDIF
hMsg := hb_jsonDecode( cMsg )
IF hMsg["action"] == "subscribe"
aChannels := iif( hb_HHasKey(s_hSubs, oConn), s_hSubs[oConn], {} )
AAdd( aChannels, hMsg["channel"] )
s_hSubs[oConn] := aChannels
oConn:Send( hb_jsonEncode( { "ok" => .T., "channel" => hMsg["channel"] } ) )
ENDIF
}
// Publish to a specific channel
PROCEDURE WsPublish( cChannel, cPayload )
LOCAL oConn, aChans
FOR EACH oConn IN hb_HKeys( s_hSubs )
aChans := s_hSubs[ oConn ]
IF AScan( aChans, cChannel ) > 0 .AND. ! oConn:lClosed
oConn:Send( cPayload )
ENDIF
NEXT
RETURN
Binary messages — send bytes¶
The client receives a Blob (browser) or Buffer (Node):
ws.binaryType = "arraybuffer"
ws.onmessage = (e) => {
if (e.data instanceof ArrayBuffer) {
const bytes = new Uint8Array(e.data)
// ...
}
}
Behind a proxy¶
For a reverse proxy (Apache/Nginx) to let the WS upgrade through:
Apache¶
<VirtualHost *:443>
ServerName app.example.com
SSLEngine on
SSLCertificateFile ...
ProxyPass /ws ws://127.0.0.1:8080/ws
ProxyPassReverse /ws ws://127.0.0.1:8080/ws
ProxyPass / http://127.0.0.1:8080/
ProxyPassReverse / http://127.0.0.1:8080/
</VirtualHost>
Nginx¶
location /ws {
proxy_pass http://127.0.0.1:8080/ws;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 86400;
}
Long
proxy_read_timeout(24h here) — without it the proxy closes the WS at the first idle minute, even with ping/pong.
Common errors¶
| Symptom | Cause |
|---|---|
Connection refused WebSocket handshake failed |
WS pool out of workers — bump pool_ws.workers |
| Client disconnects at 60s | Proxy with low proxy_read_timeout (Nginx default 60s) |
Mixed Content in browser |
HTTPS page opening ws:// — use wss:// |
bOnWsMessage never fires |
Wrong opcode on client, or frame larger than buffer (>1MB) |
| WS opens and closes immediately | Client doesn't respond to ping within ping_timeout_s |
| Memory keeps growing | You're not unregistering connections in bOnWsClose |
Best practices¶
- Cleanup in
bOnWsClose. If you keep a registry of connections, delete the entry — without it, you hold references to dead sockets. - JSON for messages. Plain text is ambiguous; JSON makes the
type clear (
{ "type": "ping", "data": ... }). - Don't block the callback.
bOnWsMessageruns in the worker — if you do slow I/O there, other clients wait. For heavy tasks, dispatch to another thread. - WSS in production. Just like HTTPS: no plain WS over the internet.
- Optional heartbeat from client. Beyond HIX's ping/pong, an application message every N seconds confirms the client logic is alive.
- Protocol agreement. Define a version in the handshake or in the
first message (
{ "v": 1, ... }) — when you evolve, the client knows if it understands the server.