Skip to content

Digging Deeper

Channels

This page shows you how to use Wheels channels — a pub/sub layer on top of Server-Sent Events. You’ll publish named events from anywhere in your app, stream them to subscribed browsers from a controller action, pick the right adapter for your deployment, and wire up the JavaScript client.

You’ll learn:

  • What channels add on top of the low-level SSE API
  • Where Wheels stands on WebSockets — SSE-only in core by design, with an optional wheels-websockets package on top
  • How to publish with publish() and subscribe with subscribeToChannel()
  • When to use the memory adapter versus the database adapter
  • How to deliver the WheelsSSE JavaScript client to the browser (it is not auto-served)

The SSE guide covers the raw primitives: renderSSE() for one-shot events and initSSEStream() / sendSSEEvent() / closeSSEStream() for hand-rolled streams. With those, you write the delivery loop yourself.

Channels layer pub/sub on top. You publish a named event to a named channel from anywhere — a model callback, a background job, another controller — and any browser subscribed to that channel receives it. The framework runs the long-lived connection loop, the event filtering, the heartbeats, and the Last-Event-ID resume handling for you. The low-level SSE API keeps working unchanged; channels are built on it, not a replacement for it.

Three pieces make up the surface:

  • publish(channel, event, data) — a global function. Callable from controllers, models, jobs, and anywhere else global helpers are available.
  • subscribeToChannel(...) — a controller mixin. Call it from an action to turn that action into a long-lived SSE endpoint streaming the channel’s events.
  • channelSSETag(...) — a view helper that emits a small <script> tag opening an EventSource against your subscribe action.

Everything flows one way: server to client.

The framework core ships channels over SSE only — one-directional, server to client. That is a deliberate design choice, not a stopgap:

  • Plain HTTP. SSE rides an ordinary long-lived GET request. Every proxy, load balancer, and firewall that speaks HTTP passes it through; WebSockets need their own upgrade handshake and protocol support at every hop.
  • Free reconnection. The browser’s EventSource reconnects automatically and resends Last-Event-ID so you can resume. With WebSockets you write that yourself.
  • Auth you already have. Same-origin SSE requests carry your session cookie. No separate token handshake for a second protocol.

AdonisJS made the same call with its Transmit package — SSE-only, no WebSocket server — for the same reasons.

For the client-to-server direction, use what you already have: an ordinary HTTP POST. The action saves whatever it needs to and calls publish() to fan the result out to subscribers. The chat room pattern below is exactly this — and it covers most “I need WebSockets” use cases.

Optional WebSocket transport: the wheels-websockets package

Section titled “Optional WebSocket transport: the wheels-websockets package”

When you do want events delivered over a real WebSocket, the first-party wheels-websockets package (v0.2.0) adds an opt-in WebSocket transport on top of channels:

illustrative — install the package
wheels packages add wheels-websockets

Your app keeps calling publish() exactly as before. Where the engine can serve WebSockets, connected browsers get the event over a socket; everywhere else, nothing changes and SSE channels keep working. Engine support:

  • RustCFML — native transport, supported since v0.1.0.
  • Lucee 6.2+ — via the official lucee/extension-websocket, verified live as of v0.2.0. This is the verified stock-Lucee path today.
  • Lucee 7 — needs an engine at 7.0.2.7 or newer and a jakarta-compatible release of the websocket extension, which hasn’t been published yet. Until it ships, the package detects the situation, logs one warning, and channels keep working over SSE.
  • Adobe CF / BoxLang — no WebSocket backend yet (demand-gated on discussion #3286); channels degrade gracefully to SSE.

The package’s WheelsRealtime JavaScript client falls back to the stock WheelsSSE client automatically, so you get one subscription API on every engine. Installing the package on an unsupported engine is always safe — it logs one line and stays on SSE.

A subscribe action, a route to it, a publish call, and a view tag.

app/controllers/Notifications.cfc
component extends="Controller" {
function stream() {
// Derive the channel server-side — don't trust a client-supplied channel name
subscribeToChannel(channel = "user.#session.userId#");
}
}
config/routes.cfm (excerpt)
.get(name = "notificationStream", pattern = "notifications/stream", to = "notifications##stream")

Publish from anywhere — here, a model callback:

app/models/Order.cfc
component extends="Model" {
function config() {
afterCreate("notifyUser");
}
private function notifyUser() {
publish(
channel = "user.#this.userId#",
event = "message",
data = SerializeJSON({title: "Order received", orderId: this.id})
);
}
}

In the view, channelSSETag() emits a <script> tag that opens an EventSource against the subscribe endpoint:

app/views/orders/index.cfm (excerpt)
#channelSSETag(channel="user.#session.userId#", route="notificationStream")#

The tag sends its channel argument to the endpoint as a channel URL parameter, but the stream() action above deliberately ignores it and derives the channel from the session — a client editing the URL can’t subscribe to another user’s notifications. An endpoint that serves more than one channel can subscribe with subscribeToChannel(channel = params.channel) instead, after validating the subscription — see channelSSETag().

The generated script relays incoming events as wheels:sse CustomEvents on document, with {data, event, id} in e.detail:

illustrative — browser JS
document.addEventListener('wheels:sse', (e) => {
const payload = JSON.parse(e.detail.data);
showToast(payload.title);
});

That’s it — publish with the default message event type, render channelSSETag() once per page, listen for wheels:sse on document. Named event types and per-event listeners are covered under channelSSETag() and the JavaScript client below.

Set the default adapter in your settings file:

config/settings.cfm (excerpt)
set(channelAdapter = "memory"); // or "database"

When channelAdapter is not set, the default is "memory". Both publish() and subscribeToChannel() also accept a per-call adapter argument that overrides the global setting — useful when one high-traffic channel needs persistence and the rest don’t.

In-process pub/sub backed by a ConcurrentHashMap. Delivery is instant — publish() invokes each subscriber’s callback synchronously on the publishing request, and the subscriber’s streaming loop flushes it to the browser.

  • Best for: single-server deployments and development.
  • No persistence across restarts: events live in the application’s memory. Each channel keeps its last 100 events, so a reconnect that sends a last event ID replays the events published after it. If that ID has already dropped out of the 100, the whole retained log is replayed.
  • Single-server only: subscribers on server A never see events published on server B.
  • A channel’s subscriber list is pruned when its last subscriber disconnects, but its event log (up to 100 events) stays in memory for the life of the application. Per-entity channel names like user.42 therefore each keep a small log; the database adapter has no such cost.

Persists every event to a wheels_events table, which is auto-created on first use (no migration needed). Subscribers poll the table — every pollInterval seconds (default 2) — so events published on any server reach subscribers on every server.

  • Best for: multi-server deployments, and anywhere you want resume/replay (events persist, so Last-Event-ID recovery works across reconnects).
  • Latency is bounded by the poll interval rather than instant.
  • Events are retained for 60 minutes by default.
  • A subscriber that connects without a last event ID first receives the events published in the last 5 minutes.

The table schema:

ColumnTypeDescription
idVARCHAR(36), primary keyEvent UUID
channelVARCHAR(255)Channel name
eventVARCHAR(255)Event type
dataTEXT / CLOBEvent payload
createdAtDATETIME / TIMESTAMPWhen the event was published

Indexes: (channel, createdAt) and (createdAt).

Expired events are swept opportunistically on the publish path: at most once every 5 minutes (every 15 seconds while a backlog is draining), deleting at most 1,000 of the oldest expired rows per pass so no single publish() call blocks on a large DELETE. On busy multi-server deployments, don’t rely solely on that — run a full sweep from a scheduled task or worker:

illustrative — scheduled task body
var adapter = $getChannelEngine("database");
adapter.cleanup(olderThanMinutes = 60, maxRows = 10000);

cleanup() returns the number of rows deleted. maxRows = 0 (the default) deletes all expired rows in one pass.

Publish an event to a channel. Available anywhere global helpers are — controllers, models, jobs, views.

ParameterTypeDefaultDescription
channelstringrequiredChannel name (e.g. "user.42", "orders")
eventstringrequiredEvent type (e.g. "notification", "update")
datastringrequiredEvent payload (typically JSON via SerializeJSON())
adapterstring"""memory" or "database"; empty falls back to the channelAdapter setting

The return struct depends on the adapter:

  • memory: {id, channel, event, subscriberCount, timestamp} — subscriberCount is how many subscribers the event was delivered to.
  • database: {id, channel, event, persisted}. If the insert fails, the error is logged to wheels_channels.log and publish() throws Wheels.Channel.PublishFailed.

Call from a controller action to open a long-lived SSE connection streaming a channel’s events until the client disconnects or the timeout elapses. When the connection ends, the browser’s EventSource reconnects automatically and the next request resumes — subscribeToChannel() reads the resume point on its own when lastEventId isn’t passed: the Last-Event-ID header a native EventSource sends, or the lastEventId url parameter the bundled WheelsSSE client sends (the header wins if both are present). You no longer have to forward params.lastEventId from the action yourself.

ParameterTypeDefaultDescription
channelstringrequiredChannel to subscribe to
eventsstring""Comma-delimited event types to deliver (surrounding whitespace is ignored, so "a, b" works); empty delivers all
lastEventIdstring""Resume after this event ID; auto-detected from the Last-Event-ID header or the lastEventId url parameter when empty
adapterstring""Per-call adapter override
pollIntervalnumeric2Seconds between table polls (database adapter only)
timeoutnumeric300Maximum connection duration in seconds
heartbeatIntervalnumeric15Seconds between keep-alive comment pings

With the memory adapter, the action subscribes in-process and streams events as they’re published. With the database adapter, it polls wheels_events every pollInterval seconds.

Generate a <script> tag that opens an EventSource against a subscribe endpoint and relays events as wheels:sse CustomEvents on document.

ParameterTypeDefaultDescription
channelstringrequiredChannel name (added as a channel URL parameter)
routestring""Named route for the SSE endpoint
controllerstring""Controller name, used with action when no route is given
actionstring"stream"Action name
eventsstring""Comma-delimited event types (added as an events URL parameter)

You must pass either route or controller — otherwise it throws Wheels.Channel.MissingEndpoint. Note that the channel and events arrive at your action as URL parameters; your action decides what to do with them (typically subscribeToChannel(channel=params.channel, events=params.events ?: "") after validating the client may subscribe).

Wheels ships WheelsSSE, a zero-dependency EventSource wrapper with typed listeners, Last-Event-ID tracking, and exponential-backoff reconnection.

app/views/layout.cfm (excerpt)
#javaScriptIncludeTag("wheels-sse")#
illustrative — browser JS
const sse = new WheelsSSE('/notifications/stream', {
channel: 'user.42',
events: ['notification', 'alert'],
onMessage: (data, event, id) => console.log(event, data)
});
// Typed listeners for named event types
sse.on('notification', (data, id) => {
showNotification(data.title, data.body);
});
// Later
sse.off('notification', handler);
sse.close();
// Static factory
const orders = WheelsSSE.subscribe('/orders/stream', {channel: 'orders'});

channel, events, and lastEventId are appended to the URL as query parameters. Event data is JSON.parsed when possible; otherwise handlers receive the raw string.

OptionTypeDefaultDescription
channelstring""Channel name (added as a URL parameter)
eventsstring[][]Event types to filter (added as a URL parameter)
lastEventIdstring""Resume from this event ID
reconnectIntervalnumber1000Initial reconnect delay (ms)
maxReconnectIntervalnumber30000Maximum reconnect delay (ms)
reconnectDecaynumber2Backoff multiplier per attempt
maxRetriesnumber0Maximum reconnect attempts (0 = unlimited)
onOpenfunctionnullCalled when the connection opens
onErrorfunctionnullCalled on connection error
onMessagefunctionnullCalled for every event: (data, event, id)
  • on(event, callback) — add a typed event listener. Returns this for chaining.
  • off(event, callback) — remove a listener. Returns this.
  • close() — disconnect and stop reconnecting.
  • lastEventId (getter) — the last event ID received.

On connection error the client closes the source and retries: the delay starts at reconnectInterval, multiplies by reconnectDecay each attempt, and caps at maxReconnectInterval. A successful connection resets the backoff. With maxRetries = 0 it retries forever; any other value stops after that many attempts.

One channel per user; publish from wherever the event originates.

app/controllers/Notifications.cfc
component extends="Controller" {
function stream() {
subscribeToChannel(channel = "user.#session.userId#");
}
}
illustrative — publish from a job or callback
publish(
channel = "user.#user.id#",
event = "notification",
data = SerializeJSON({title: "Order shipped", orderId: order.id})
);

The client-to-server direction is a plain POST: the send action persists the message and publishes it to everyone subscribed to the room. No WebSocket required.

app/controllers/Chat.cfc
component extends="Controller" {
function messages() {
subscribeToChannel(
channel = "chat.room.#params.roomId#",
events = "message,typing"
);
}
function send() {
model("Message").create(
roomId = params.roomId,
userId = session.userId,
text = params.text
);
publish(
channel = "chat.room.#params.roomId#",
event = "message",
data = SerializeJSON({user: session.userName, text: params.text})
);
renderNothing();
}
}

A background job publishes metrics; dashboards on every server in the cluster pick them up. A longer poll interval and timeout suit slow-moving data.

app/controllers/Dashboards.cfc
component extends="Controller" {
function metrics() {
subscribeToChannel(
channel = "dashboard.metrics",
adapter = "database",
pollInterval = 5,
timeout = 600
);
}
}
illustrative — inside a background job's perform()
publish(
channel = "dashboard.metrics",
event = "metrics",
data = SerializeJSON(calculateMetrics()),
adapter = "database"
);

A subscribe action is an ordinary controller action — gate it with filters exactly like anything else. Channels have no access control of their own: subscribeToChannel() accepts any non-empty name, and the channel arrives as a URL parameter the client can edit, so derive the channel on the server or check that the user may read it.

Filters run once, when the connection opens. Events are not re-checked as they stream, so a user whose access is revoked keeps receiving a channel’s events until the connection ends: at most timeout seconds, after which the browser reconnects and your filters run again. Use a shorter timeout on sensitive channels. Remember that the browser’s EventSource can’t set custom headers, so token-in-header schemes don’t apply; the SSE guide’s authentication section covers the workable alternatives (same-origin session cookies being the default for a Wheels app subscribing to its own channels).

Channel subscriptions are long-lived requests, so the same deployment rules apply as for raw SSE streams: bump the request timeout for endpoints meant to outlive your engine’s default (see Long-lived connections and timeouts) and make sure your reverse proxy doesn’t buffer text/event-stream responses (see Through reverse proxies).