Real-Time Without WebSockets: Server-Sent Events in Wheels 4.0 #3247
bpamiri
announced in
Announcements
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
A user opens your dashboard and leaves the tab open. A report finishes, an order ships, a teammate comments — and the page sits there, stale, until they hit refresh. The reflex is "add WebSockets," but WebSockets are bidirectional, and most of what we call "real-time" is one-directional: the server has news, the client wants to hear it. This post is about the right-sized tool for that — Server-Sent Events — and the first-class controller helpers Wheels 4.0 ships for them.
Read: https://blog.wheels.dev/posts/server-sent-events-wheels-4
The post is a worked walkthrough of a notifications feature, both the one-shot and streaming shapes, plus an honest sharp-edges section. Here's what it covers.
Two shapes, one decision
There are exactly two ways to send SSE in Wheels, and picking between them is the only real architecture decision:
renderSSE()— one event per request, then the request ends. The browser'sEventSourcereconnects on its own to pull the next event. It's polling in SSE clothing. Routes throughrenderText(), so after-filters still run. Spec-covered, engine-safe, holds no worker thread.initSSEStream()+sendSSEEvent()+sendSSEComment()+closeSSEStream()— many events over one held-open connection. Bypasses the rendering pipeline (and layouts, and after-filters). Holds a worker thread for the whole connection lifetime. More power, more strings attached.The one-shot model
renderSSE(data=..., event=..., retry=...)is about four lines. Three things people get wrong, all covered in the post:dataisrequired string, there's no auto-JSON, and finders return query objects, soSerializeJSON()it.event(the SSE event-type name), noteventName/type/name.retry(milliseconds) is real but absent from the CLAUDE.md summary — it's your polling cadence for the reconnect-driven model.The client side is just the browser:
new EventSource(url)plusaddEventListener(eventName, ...). No library, no reconnect logic, no backoff — the browser owns all of it.Content negotiation with
isSSERequest()isSSERequest()checks theAcceptheader (EventSource always sendstext/event-stream) so one action can serve a live stream to EventSource clients and plain HTML to browser navigations. The post shows the branch, with thecfparamand<cfloop query="...">on the HTML side.The streaming trio and the bounded loop
initSSEStream()takes no args and returns the raw output writer. You thensendSSEEvent()(writes + flushes immediately),sendSSEComment()(the keep-alive heartbeat — also undocumented in CLAUDE.md, also the canonical primitive the Channels loops use), andcloseSSEStream()(flush + close, forgiving of an already-disconnected client).The load-bearing part: CFML holds a worker thread for the whole connection, so a naive
while(true)is a thread leak. The post shows the mandatory pattern — wall-clock timeout cap,writer.checkError()disconnect detection, heartbeat comments on an interval,sleep()between polls, all in atry/finallythat closes the stream — mirroring the loop Wheels uses internally for Channels.The sharp edges (the honest part)
initSSEStream()is engine-fragile on BoxLang. Its adapter deliberately returns thePageContext, not theHttpServletResponse, sosetContentType/setHeader/getWriteraren't guaranteed to resolve the same way. The streaming path is also not covered end-to-end by the spec. Hand-test streaming on every engine you deploy to.renderSSE,isSSERequest, and the formatter are spec-covered and behave identically everywhere.renderSSEsends ONE event — not a stream. Multi-event push requires the trio and your own loop.renderSSEbut are skipped for streaming (the pipeline bypass). Response logging / header injection in an after-filter silently won't fire on streaming routes.id/event, data re-prefixed line-by-line — so user data can't smuggle a fakeevent:field. Embedded newlines become extradata:lines, which is correct SSE behavior.Discussion
What I'd love feedback on: are people running SSE in production on Wheels yet, and on which engines? The BoxLang streaming caveat is the spot where I'd most like to hear real field reports — if you've hand-tested
initSSEStreamthere, I want to know how it went. And if there's appetite for the streaming path to get spec coverage that works across engines, that's worth scoping. Thoughts welcome in this thread.All reactions