π‘ SSE - Server-Sent Events¶
Server-Sent Events is unidirectional push from server to client
over normal HTTP. The client opens the connection with a GET and keeps
it open; the server sends "events" in text/event-stream format
without ever closing the response (or until a timeout).
Browser ββ GET /events ββββββββββββββ>HIX
β Accept: text/event-stream β
β β USendStreamStart("text/event-stream", 200)
β<ββ 200 OK + headers stream βββββββββ€
β β
β<ββ data: {"n":1}\n\n βββββββββββββββ€ USendChunk(...)
β<ββ data: {"n":2}\n\n βββββββββββββββ€ USendChunk(...)
β<ββ data: {"n":3}\n\n βββββββββββββββ€ USendChunk(...)
β β
β β ...for minutes/hours
Vs WebSocket:
- Simpler: normal HTTP, no upgrade needed, passes through proxies and firewalls without any configuration.
- Server β client only: the client cannot send anything on the same channel (use a separate POST if needed).
- Auto-reconnect: the browser reconnects automatically if the channel drops β WS doesn't do that.
When to use it¶
| Case | SSE |
|---|---|
| Live-updating dashboard | β Ideal |
| Web console log stream | β Ideal |
| Push notifications (one-way) | β Ideal |
| Bidirectional chat | β Better WebSocket |
| Binary data (images, audio) | β SSE is text-only β use WS |
| Client with unstable internet | β Reconnects automatically |
| Standard request/response behavior | β Normal HTTP |
text/event-stream format¶
Each event is one or more key: value fields followed by a
blank line:
data: hello
data: {"n":1}
event: notice
data: {"msg":"update"}
id: 42
data: {"order":42,"status":"shipped"}
retry: 5000
data: configures retry only, no useful payload
| Field | Meaning |
|---|---|
data: |
Payload β the blank line at the end closes the event |
event: |
Event name β the client can filter by name |
id: |
Identifier β the browser sends it as Last-Event-ID on reconnect |
retry: |
Milliseconds to wait for reconnection |
: comment |
Comment / keep-alive β the browser ignores it |
Each chunk you send must end in \n\n (blank line) for the
browser to process the event.
Setup¶
Dedicated pool in hix.json¶
workers_sse: maximum concurrent SSE connections.
stream_timeout_s: maximum seconds per connection (0 = no limit).
{
"pool_rest": {
"workers_sse": 20,
"workers_longpoll": 10,
"queue_size": 128,
"stream_timeout_s": 3600
}
}
Each SSE connection occupies one worker from the
pool_restpool until it closes. 20 simultaneous connections =workers_sse = 20. If you expect 200 users viewing the live dashboard, bumpworkers_sseto 200.
SSE route¶
oSrv:AddRouteGet( "events", "/events", {||
_StreamEvents()
} )
FUNCTION _StreamEvents()
LOCAL i := 0
USendStreamStart( "text/event-stream", 200, { ;
"Cache-Control" => "no-cache", ;
"X-Accel-Buffering" => "no" ;
} )
DO WHILE i < 100
i++
USendChunk( "data: " + hb_jsonEncode( { "n" => i } ) + hb_eol() + hb_eol() )
hb_idleSleep( 1 )
ENDDO
USendStreamEnd()
RETURN NIL
X-Accel-Buffering: nodisables Nginx buffering β without it, Nginx may hold your chunks in memory until the "complete" response arrives and the client won't see anything in real-time.
Stream API¶
| Function | What it does |
|---|---|
USendStreamStart( cMime, nStatus, hExtra ) |
Sends headers + opens the stream |
USendChunk( cData ) |
Sends a chunk (a complete SSE event, with final \n\n) |
USendStreamEnd() |
Closes the stream cleanly |
Internally HIX uses transfer-encoding chunked and keeps the socket open between calls.
JavaScript client¶
const evt = new EventSource("/events")
evt.onmessage = (e) => {
// event without "event:" β onmessage
const data = JSON.parse(e.data)
console.log("Tick:", data.n)
}
evt.addEventListener("notice", (e) => {
console.log("Notice:", e.data)
})
evt.onerror = (e) => {
// The browser will retry automatically
console.log("Error / disconnection", e)
}
// Close manually
// evt.close()
The browser reconnects automatically. If you need to avoid duplicates, send
id:in each event and on reconnect the browser will attach theLast-Event-IDheader automatically.
Useful patterns¶
Heartbeat to prevent proxy timeouts¶
Proxies close idle sockets (60β120s typically). Send a
comment : every X seconds:
DO WHILE lRunning
IF Seconds() - nLastBeat >= 20
USendChunk( ": keep-alive" + hb_eol() + hb_eol() )
nLastBeat := Seconds()
ENDIF
IF _HayNuevoEvento( @hEvent )
USendChunk( "data: " + hb_jsonEncode( hEvent ) + hb_eol() + hb_eol() )
ENDIF
hb_idleSleep( 1 )
ENDDO
Named events and client-side filtering¶
USendChunk( "event: order_update" + hb_eol() + ;
"data: " + hb_jsonEncode( hOrder ) + hb_eol() + hb_eol() )
USendChunk( "event: alert" + hb_eol() + ;
"data: " + hb_jsonEncode( hAlert ) + hb_eol() + hb_eol() )
Recover after reconnection (Last-Event-ID)¶
FUNCTION _StreamEvents()
LOCAL nLastId := Val( UHeader( "last-event-id", "0" ) )
USendStreamStart( "text/event-stream", 200, { "Cache-Control" => "no-cache" } )
// Re-emit lost events since nLastId
FOR EACH hEvent IN _GetEventsSince( nLastId )
USendChunk( "id: " + hb_NToS( hEvent["id"] ) + hb_eol() + ;
"data: " + hb_jsonEncode( hEvent ) + hb_eol() + hb_eol() )
NEXT
// Continue with new events
DO WHILE _HayMas( nLastId, @hNext )
USendChunk( "id: " + hb_NToS( hNext["id"] ) + hb_eol() + ;
"data: " + hb_jsonEncode( hNext ) + hb_eol() + hb_eol() )
nLastId := hNext["id"]
hb_idleSleep( 1 )
ENDDO
USendStreamEnd()
RETURN NIL
Client closes β exit the loop¶
Detecting client disconnection is tricky: in standard HTTP the server doesn't get immediate notification. The usual approach is to check the error flag when writing:
DO WHILE lRunning
USendChunk( "data: " + hb_jsonEncode( hEvent ) + hb_eol() + hb_eol() )
IF UContext():oReq:lClosed
lRunning := .F.
ENDIF
hb_idleSleep( 1 )
ENDDO
USendStreamEnd()
Always limit with
stream_timeout_sin the pool. Even if you don't detect the disconnection, the worker is freed when the timeout expires.
SSE Broadcast¶
Chunks are sent per connection. If you want to send the same event to N clients, maintain a list:
STATIC s_aClients := {}
STATIC s_oMutex := NIL
INIT PROCEDURE _InitSseRegistry()
s_oMutex := hb_mutexCreate()
RETURN
FUNCTION _SseStream()
LOCAL hClient := { "id" => hb_Random(), "queue" => {} }
LOCAL cMsg
hb_mutexLock( s_oMutex )
AAdd( s_aClients, hClient )
hb_mutexUnlock( s_oMutex )
USendStreamStart( "text/event-stream", 200, { "Cache-Control" => "no-cache" } )
DO WHILE ! UContext():oReq:lClosed
hb_mutexLock( s_oMutex )
DO WHILE Len( hClient["queue"] ) > 0
cMsg := hClient["queue"][1]
hb_ADel( hClient["queue"], 1, .T. )
USendChunk( cMsg )
ENDDO
hb_mutexUnlock( s_oMutex )
hb_idleSleep( 0.5 )
ENDDO
// Unregister
hb_mutexLock( s_oMutex )
AEval( s_aClients, {|h,n| iif( h["id"] == hClient["id"], hb_ADel(s_aClients,n,.T.), NIL ) } )
hb_mutexUnlock( s_oMutex )
USendStreamEnd()
RETURN NIL
PROCEDURE SseBroadcast( cData )
LOCAL hClient
LOCAL cChunk := "data: " + cData + hb_eol() + hb_eol()
hb_mutexLock( s_oMutex )
FOR EACH hClient IN s_aClients
AAdd( hClient["queue"], cChunk )
NEXT
hb_mutexUnlock( s_oMutex )
RETURN
And from any HTTP action:
Behind a proxy¶
| Proxy | Trick |
|---|---|
| Nginx | proxy_buffering off; + proxy_read_timeout 24h; |
| Apache | Normal ProxyPass β works |
| Cloudflare | Works, but with 100s timeout β send heartbeats every 30s |
Nginx¶
location /events {
proxy_pass http://127.0.0.1:8080/events;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_buffering off; # critical for SSE
proxy_cache off;
proxy_read_timeout 24h;
}
The
X-Accel-Buffering: noheader in the response also disables it per request β useful if you don't control your Nginx config.
Common errors¶
| Symptom | Cause |
|---|---|
| Client receives everything when stream closes, not in real-time | Proxy buffering β add X-Accel-Buffering: no |
| Client reconnects every 60s | Proxy with low proxy_read_timeout β raise to hours |
| Events arrive duplicated on reconnect | You don't use id: β the browser re-receives the last ones |
Wrong Content-Type |
Forgot text/event-stream in USendStreamStart |
| Only first event shows | Missing \n\n at the end of each chunk |
| SSE pool exhausted | pool_rest.workers_sse too low for expected concurrency |
| Memory leak with clients closing silently | Loop doesn't check lClosed or missing stream_timeout_s |
Best practices¶
- Always
\n\nat the end of the chunk. Without the blank line, the browser accumulates without firing the event. X-Accel-Buffering: noin headers β protects against Nginx by default.- Heartbeats every 20β30s. Comments
:keep the socket alive and you detect disconnection sooner. - Use
id:if order matters. WithLast-Event-IDyou can resume from where it broke. - Limit with
stream_timeout_s. A hung SSE connection blocks a worker β the hard timeout frees it. - Don't block with synchronous I/O. Each SSE lives in its worker; if you wait for slow DB, the client waits too. Better an intermediate buffer + dedicated worker.