Broadcasting
@rudderjs/broadcast is the framework's WebSocket pub/sub layer. It runs on the same port as your HTTP server, supports public, private, and presence channels, and ships a small client (RudderSocket) for the browser. No Pusher account, no separate process, no external service.
Setup
pnpm add @rudderjs/broadcast// bootstrap/providers.ts
import { BroadcastingProvider } from '@rudderjs/broadcast'
export default [
...(await defaultProviders()),
BroadcastingProvider,
]Add a channel-routes loader to bootstrap/app.ts:
Application.configure({ ... })
.withRouting({
web: () => import('../routes/web.ts'),
api: () => import('../routes/api.ts'),
channels: () => import('../routes/channels.ts'),
})
.create()channels.ts is where you register auth callbacks for non-public channels:
// routes/channels.ts
import { Broadcast } from '@rudderjs/broadcast'
Broadcast.channel('private-orders.*', async (req) => {
const user = await verifyToken(req.token)
return !!user
})
Broadcast.channel('presence-room.*', async (req) => {
const user = await verifyToken(req.token)
if (!user) return false
return { id: user.id, name: user.name }
})Channel types
| Type | Prefix | Auth |
|---|---|---|
| Public | none | None — anyone can subscribe |
| Private | private- |
Auth callback must return truthy |
| Presence | presence- |
Auth callback returns the member info object (or false to deny) |
The pattern after the prefix supports * as a wildcard matching non-dot segments — private-user.* matches private-user.42 but not private-user.42.profile.
Broadcasting events
Push events from anywhere on the server — route handlers, jobs, services, listeners:
import { broadcast } from '@rudderjs/broadcast'
Route.post('/orders/:id/ship', async (req) => {
await shipOrder(req.params.id)
await broadcast(`orders.${req.params.id}`, 'order.shipped', { id: req.params.id })
return { ok: true }
})broadcast(channel, event, payload) returns a Promise<void> — it resolves once the configured driver has accepted the message. On the default LocalDriver that's same-tick, effectively fire-and-forget. With a multi-instance driver (see below) it resolves after one Redis round-trip. Subscribed clients receive the event asynchronously over the WebSocket.
Multi-instance deployments
@rudderjs/broadcast ships an in-process LocalDriver by default. It walks the local subscriber map — fine for a single Node process, but a broadcast() call from any other instance silently delivers to nobody as soon as you scale beyond one process.
Install @rudderjs/broadcast-redis and point the broadcast config at it:
pnpm add @rudderjs/broadcast-redis ioredis// config/broadcast.ts
import type { BroadcastConfig } from '@rudderjs/broadcast'
import { RedisDriver } from '@rudderjs/broadcast-redis'
const config: BroadcastConfig = {
driver: () => new RedisDriver({ redis: process.env.REDIS_URL! }),
}
export default configThat's it. Channel auth, server-originated broadcast() events, telescope, the rudder commands all fan across the cluster. Run pnpm rudder doctor --deep to verify REDIS_URL is set and reachable.
Presence channels are per-instance. Presence rosters and
presence.joined/presence.leftevents are tracked in each instance's memory and are NOT fanned across the driver, so a member connected to instance A is absent from the roster computed on instance B. Regularbroadcast()andclient-eventtraffic IS cluster-wide. If you need a single cluster-wide roster, pin presence-channel clients to one instance (sticky sessions) or aggregate roster state yourself. The provider logs a one-time notice at boot when a cross-instance driver is configured.
The browser client
Publish the client into your project:
pnpm rudder vendor:publish --tag=broadcast-client
# → src/RudderSocket.tsUse it from frontend code:
import { RudderSocket } from './RudderSocket'
const socket = new RudderSocket('ws://localhost:3000/ws')
// Public channel — no auth
const chat = socket.channel('chat')
chat.on('new-message', (msg) => console.log(msg.text))
// Private channel — auth callback fires server-side
const orders = socket.private(`orders.${orderId}`, userToken)
orders.on('order.shipped', (data) => showNotification(`Order ${data.id} shipped!`))
// Client events — broadcast back to other subscribers
chat.emit('typing', { user: 'Alice' })
// Presence — track who's in a room
const room = socket.presence('room.lobby', userToken)
room.on('presence.members', (members) => console.log('Online:', members))
room.on('presence.joined', (user) => console.log(`${user.name} joined`))
room.on('presence.left', (user) => console.log(`${user.name} left`))
orders.leave()RudderSocket reconnects automatically after 3 seconds on disconnect and resubscribes to every active channel after reconnecting. The token is re-sent on each reconnect so the server can re-validate.
Stats
import { broadcastStats } from '@rudderjs/broadcast'
const { connections, channels } = broadcastStats()Or the rudder command:
pnpm rudder broadcast:connectionsAuth callback inputs
The auth callback receives the upgrade-time request and the channel name:
Broadcast.channel('private-user.*', async (req, channelName) => {
// req.headers — HTTP headers from the WebSocket upgrade
// req.url — upgrade request URL
// req.token — token from the client's subscribe message
// channelName — the resolved channel string (e.g. 'private-user.42')
const user = await verifyToken(req.token)
return !!user && user.id === parseUserIdFromChannel(channelName)
})For presence channels, returning a member object publishes that member's metadata to other subscribers. Returning false denies the subscription.
How it shares the HTTP port
Broadcasting hooks into Node's HTTP upgrade event on your existing server. In dev, @rudderjs/vite patches http.createServer so srvx's server gets the upgrade handler. In production, @rudderjs/server-hono's listen() attaches it after serve() starts. The handler chain is:
HTTP server → /ws → @rudderjs/broadcast
→ /ws-sync → @rudderjs/sync
→ /<other> → Vite HMR (dev only) or 404Both real-time packages can coexist in the same process — they own different paths.
Pitfalls
- Channel auth callback running too late. The callback fires on subscribe, not on every event. Re-validate inside the auth callback if your token can expire mid-session — or rely on token TTL + reconnect.
- Forgetting
channels: () => import('./routes/channels.ts'). The provider boots cleanly but no auth callbacks are registered, so private/presence channels reject everyone. - Sending sensitive data to public channels. Public means unauthenticated. Treat anything you broadcast there as world-readable.
- Browser client without
RudderSocket. Hand-rolling the WebSocket protocol is fiddly — the client handles framing, channel multiplexing, and reconnect resubscribe. Vendor it once and forget.