Digging Deeper
Server-Sent Events
This page shows you how to push data from a Wheels controller to a browser over a plain HTTP connection. You’ll render a one-shot SSE event, stream many events over a long-lived connection, detect EventSource clients for content negotiation, and handle the reverse-proxy and auth quirks that trip up first-time SSE deployments.
You’ll learn:
- When SSE is the right choice versus WebSockets
- How to render a single SSE event from a controller action with
renderSSE() - How to stream many events over one connection with
initSSEStream()/sendSSEEvent()/closeSSEStream() - How to consume events from the browser’s
EventSourceAPI - How to keep long-lived connections alive through timeouts and reverse proxies
- How to authenticate SSE requests when the browser won’t let you set custom headers
SSE vs WebSockets
Section titled “SSE vs WebSockets”SSE (Server-Sent Events) is a one-way channel from server to client over plain HTTP. The browser opens a long-lived GET request with Accept: text/event-stream; the server keeps writing event frames to the response body; the browser reconnects automatically if the connection drops.
- SSE: server → client only, plain HTTP, built-in auto-reconnect, passes through every proxy and firewall that speaks HTTP.
- WebSockets: bidirectional, separate
ws://protocol, richer feature set but harder to deploy behind a reverse proxy and requires its own connection lifecycle.
Use SSE when the client only needs to listen — notifications, live dashboards, progress bars, log tailing, incremental search results. Reach for WebSockets when the client also needs to push — but note that Wheels itself ships no WebSocket layer; for the higher-level pub/sub option built on SSE (and the full story on what does and doesn’t exist), see Channels.
One-shot SSE response
Section titled “One-shot SSE response”The simplest pattern: a controller action responds to an EventSource request with a single event. The browser reconnects automatically to pick up the next one. This is cheap and plays well with horizontal scaling — no pinned connection, no sticky sessions.
component extends="Controller" { function poll() { data = model("Notification").findAll( where="userId=##params.userId## AND sent=0", order="createdAt DESC", maxRows=10 ); renderSSE( data=SerializeJSON(data), event="notifications", id=params.lastId ?: "" ); }}renderSSE() sets the SSE headers (Content-Type: text/event-stream, Cache-Control: no-cache, Connection: keep-alive, X-Accel-Buffering: no), formats the event, and writes it as the response body. Its arguments:
data— the event body. Serialize JSON or plain text; multi-line strings are split into separatedata:lines per the SSE spec.event— the event type name. Clients subscribe withaddEventListener('notifications', …).id— the event ID. On reconnect, the browser sends it back as theLast-Event-IDrequest header so you can resume.retry— optional reconnection delay in milliseconds. Omit to let the browser default (~3 seconds) apply.
Streaming multiple events
Section titled “Streaming multiple events”For a single connection that pushes many events over its lifetime, use the streaming API. This bypasses the normal Wheels render pipeline — no layout, no after-filters — and writes directly to the underlying response writer.
component extends="Controller" { function stream() { writer = initSSEStream(); try { items = model("Activity").findAll( where="createdAt > '##params.since##'", order="createdAt" ); for (var item in items) { sendSSEEvent( writer=writer, data=SerializeJSON(item), event="update", id=item.id ); } } finally { closeSSEStream(writer=writer); } }}Three calls do the work:
initSSEStream()sets the headers, grabs the response writer, and tells the framework to skip its own rendering. It returns the writer you pass to subsequent calls.sendSSEEvent()formats one event and flushes it to the stream. Call it as many times as you need.closeSSEStream()flushes one last time and closes the connection. Put it in afinally— without it, the connection hangs until the request timeout kicks in.
For keep-alive pings on an otherwise idle stream, call sendSSEComment(writer=writer, comment="ping"). Comments are SSE-protocol lines that start with :, which the browser ignores. They keep proxies from closing an “idle” connection.
Detecting an SSE request
Section titled “Detecting an SSE request”Serve both HTML and SSE from the same route by checking what the client asked for. isSSERequest() returns true when the request’s Accept header contains text/event-stream, which is what EventSource sends.
component extends="Controller" { function index() { data = model("Notification").findAll(where="userId=##params.userId##"); if (isSSERequest()) { renderSSE(data=SerializeJSON(data), event="notifications"); } else { renderWith(data=data); } }}One action, two response formats. Browsers hitting /notifications get the HTML view; an EventSource('/notifications') gets SSE frames.
Client-side
Section titled “Client-side”The browser side is the standard EventSource API. No library required.
const es = new EventSource('/notifications/poll?userId=42');
es.addEventListener('notifications', (e) => { const data = JSON.parse(e.data); // update UI});
es.onerror = () => { // the browser auto-reconnects; log or alert if needed};addEventListener(name, …) subscribes to the event: type you sent from the server. If you omit event= on renderSSE(), events arrive as generic message events — use es.onmessage to catch them.
Auto-reconnect and event IDs
Section titled “Auto-reconnect and event IDs”When the connection drops — server restart, laptop sleep, flaky wifi — the browser reconnects on its own. No retry code to write.
To resume from where the client left off, set id= on each event. The browser remembers the last ID it saw and sends it as Last-Event-ID on the reconnect request. Read it in your action and only return events newer than that:
local.lastId = GetHTTPRequestData().headers["Last-Event-ID"] ?: "";local.newer = model("Notification").findAll( where="id > '##local.lastId##'", order="id");Pair this with the one-shot renderSSE() pattern for the simplest implementation — each reconnect is just a new request, and Last-Event-ID is a query parameter in all but name.
Long-lived connections and timeouts
Section titled “Long-lived connections and timeouts”Lucee and Tomcat enforce request timeouts measured in seconds, not hours. A streaming SSE endpoint will get killed mid-flight unless you raise the limit.
For endpoints that stream indefinitely, bump the timeout in the action itself:
<cfsetting requesttimeout="86400">Set it before initSSEStream(). The 24-hour value is a pragmatic cap — pure zero works on Lucee but not every engine or servlet container.
You can also configure per-endpoint timeouts in lucee.json or your application server’s config, but the inline cfsetting is the simplest and keeps the knob next to the code that needs it.
Authentication on SSE
Section titled “Authentication on SSE”The browser’s EventSource is a minimal API — you can’t set custom headers on it, which rules out the Authorization: Bearer … pattern most APIs use. Three workable alternatives:
- Session cookies — if the SSE endpoint is same-origin with the page that opened the
EventSource, the session cookie is sent automatically. This is the default for a Wheels app serving its own SSE endpoints. - Signed token in the query string — pass a short-lived, signed token (not the raw session ID) in the URL:
new EventSource('/notifications?token=…'). Validate it in a controller filter beforerenderSSE(). Log URLs accordingly — query strings show up in access logs and browser history. - HttpOnly cookie set by a companion endpoint — issue a POST that mints a short-lived auth cookie, then open the
EventSource. Keeps the token out of the URL, but adds a round-trip.
Gate the endpoint with a controller filter just like any other action. The fact that the response is SSE doesn’t change how authentication runs.
Through reverse proxies
Section titled “Through reverse proxies”Most reverse proxies buffer responses by default. Buffering breaks SSE — the browser won’t see any events until the buffer fills, and for a low-volume notification feed that might be never.
- Nginx: set
proxy_buffering off;on the location that handles SSE. Wheels setsX-Accel-Buffering: noautomatically, which Nginx respects on a per-response basis, but thelocation-level directive is the belt-and-braces version. - Cloudflare: SSE works through Cloudflare when the response streams without buffering. If you see delays, disable caching on the route and confirm
Cache-Control: no-cacheis set (Wheels does this for you). - Traefik / Caddy: streaming responses work out of the box. No extra config.
Plan for this during deployment — cross-link to the upcoming Deployment guide when it lands.