coldbox-sse-streaming
Use this skill when streaming Server-Sent Events (SSE) from a ColdBox handler or route with event.sse(), building a route that always streams with Router.toSSE(), sending data/events/errors through an SSEEmitter, reacting to SSE connect/disconnect/error via the preSSEConnection/postSSEConnection/onSSEError interception points, or configuring the this.sse settings block (keep-alive interval, reconnect hints, CORS defaults).
Server-Sent Events (SSE)
When to Use This Skill
Use this skill when a ColdBox application needs to push a stream of events to the browser over a
single long-lived HTTP response โ live notifications, progress updates, log tailing, ticking
dashboards, or streaming AI chat responses. For request/response AI streaming specifically via
toAi()'s /stream sub-route, see coldbox-ai-integration; this
skill covers first-class SSE as a general-purpose ColdBox feature.
ColdBox 8.2.0+. BoxLang only.
event.sse(),Router.toSSE(), and the SSE interception points all require BoxLang; they are not available on Lucee or Adobe ColdFusion engines.
Core Concepts
event.sse( callback )โ takes over the response and hands your callback anSSEEmitterSSEEmitterโsend(),sendView(),sendData(),sendError(), keep-alives, disconnect detectionRouter.toSSE()โ a route terminator for endpoints that always stream, mirroringtoResponse()- Interception points โ
preSSEConnection(reject before it opens),postSSEConnection(observe stream completion),onSSEError(react to mid-stream errors) this.ssesettings โ keep-alive interval, reconnect hints, CORS defaults, set per-handler or globally
Streaming from a Handler
class Notifications extends coldbox.system.EventHandler {
function ticker( event, rc, prc ) {
event.sse( ( emitter ) => {
while ( emitter.isOpen() ) {
emitter.send( { "ts": now() }, "tick" )
sleep( 1000 )
}
} )
}
}
event.sse() takes over rendering entirely โ do not also call event.setView() or
event.renderData() in the same action.
The SSEEmitter API
function stream( event, rc, prc ) {
event.sse( ( emitter ) => {
// Send a plain data payload (defaults to the "message" event name)
emitter.send( "hello" )
// Send structured data with an explicit event name
emitter.send( { "progress": 42 }, "progress" )
// Alias for data-only payloads
emitter.sendData( { "userId": 123, "action": "login" } )
// Render a view fragment and stream its output as one SSE frame
emitter.sendView( view: "partials/notification", args: { message: "New order!" } )
// Send an error frame (does not close the stream by itself)
emitter.sendError( "Something went wrong", "error" )
// Check the connection before writing โ the browser may have disconnected
if ( !emitter.isOpen() ) {
return
}
} )
}
Detecting Disconnects
Always gate long-running loops on emitter.isOpen() โ the client can close the connection (tab
closed, navigation, network drop) at any point, and writing to a closed emitter is wasted work:
function longRunningExport( event, rc, prc ) {
event.sse( ( emitter ) => {
for ( var row in exportService.streamRows() ) {
if ( !emitter.isOpen() ) {
break // client disconnected โ stop producing work
}
emitter.sendData( row )
}
} )
}
Streaming Routes โ Router.toSSE()
For an endpoint that always streams, register it directly on a route instead of writing a
one-line handler action โ toSSE() mirrors toResponse():
// config/Router.cfc
class Router extends coldbox.system.web.routing.Router {
function configure() {
route( "/notifications/stream" ).toSSE( ( event, rc, prc, emitter ) => {
while ( emitter.isOpen() ) {
emitter.send( notificationService.next(), "notification" )
}
} )
}
}
Route modifiers chained before toSSE() (withCondition, withDomain, withSSL, headers, and so
on) are inherited the same way they are for toResponse()/toAi()/toAiGateway().
SSE Interception Points
class SSEAuditInterceptor extends coldbox.system.Interceptor {
function preSSEConnection( event, rc, prc, interceptData ) {
// Reject the connection before it opens by short-circuiting (return true)
if ( !auth.isLoggedIn() ) {
event.setHTTPHeader( statusCode: 401 )
return true
}
log.info( "SSE connection opened: #event.getCurrentEvent()#" )
}
function postSSEConnection( event, rc, prc, interceptData ) {
// Fires once the stream completes (client disconnected or the callback returned)
log.info( "SSE connection closed: #event.getCurrentEvent()#" )
}
function onSSEError( event, rc, prc, interceptData ) {
// interceptData.exception holds the error that broke the stream
log.error( "SSE stream error: #interceptData.exception.message#" )
}
}
Register it like any other interceptor in config/ColdBox.cfc:
interceptors = [
{ class: "interceptors.SSEAuditInterceptor", name: "SSEAuditInterceptor" }
]
The this.sse Settings Block
Configure keep-alive interval, reconnect hints, and CORS defaults globally in config/ColdBox.cfc,
or override per-handler with this.sse on the handler component:
// config/ColdBox.cfc
class ColdBox extends coldbox.system.Coldbox {
function configure() {
coldbox.sse = {
keepAliveInterval : 15, // seconds between keep-alive comments
retry : 3000, // ms โ sent as the SSE "retry:" reconnect hint
cors : {
allowOrigin : "https://app.example.com"
}
}
}
}
// handlers/Notifications.bx โ per-handler override
class Notifications extends coldbox.system.EventHandler {
this.sse = {
keepAliveInterval : 30
}
function ticker( event, rc, prc ) {
event.sse( ( emitter ) => {
while ( emitter.isOpen() ) {
emitter.send( { "ts": now() }, "tick" )
sleep( 1000 )
}
} )
}
}
SSE Best Practices
- Always check
emitter.isOpen()inside loops โ a disconnected client should stop your work, not just stop being written to - Never call
event.setView()/event.renderData()in the same action asevent.sse()โ the emitter owns the response - Use
preSSEConnectionfor auth/authorization checks before a long-lived connection is established, not inside the streaming callback - Use
onSSEErrorfor centralized error logging/alerting rather than try/catch inside every streaming action - Tune
keepAliveIntervalbelow any intermediary proxy's idle-connection timeout to prevent silent disconnects - Prefer
Router.toSSE()over a handler action when the endpoint has no other responsibility besides streaming - For AI-generated streaming output specifically, prefer
toAi()'s/streamsub-route (seecoldbox-ai-integration) over hand-rollingevent.sse()plusaiChatStream()