Skip to main content

Channels

Looking for the product view?
The Help Center explains this surface as users see it: Live channels.

Apps can push live updates to every open page over a WebSocket that Informer owns for them. A channel is a named place a page subscribes to (orders, orders/east, @user/jane); a server-side handler calls broadcast(channel, event, payload) and every subscriber of that channel, on every server in the cluster, receives the frame a moment later. A page can answer on the same channel with channel.send(event, payload), which runs a handler of the App's on the server. The App never opens a socket, mints a credential, or talks to Redis; it names a channel on the page and broadcasts to it from the server.

The page never holds a long-lived credential and never talks to Redis; the harness owns the socket end to end.

Channels are the live counterpart of emit(): emit() writes a durable event that agents process, broadcast() sends a fire-and-forget frame to whoever is watching right now. See broadcast() versus emit() for when to reach for each.

Why channels exist​

Picture an Order Desk dashboard. A Stripe webhook records a payment, a colleague approves an order from another browser, an agent finishes triaging a ticket. Without channels the page either polls a server route every few seconds (load on the server, seconds of lag, wasted requests when nothing changed) or the user presses refresh. With channels the handler that made the change calls broadcast('orders', 'created', order) and every open dashboard updates within a moment, with no polling loop and no socket code in the App.

Channels need origin mode​

Live channels ride a WebSocket opened from the App's own origin: the page fetches /_socket on that origin, which trades the App's session for a short-lived socket credential, and the socket connects with it. That only works when the Informer server serves Apps in origin mode, that is, when the server's app.appsBaseUrl setting is configured (for example https://{label}.apps.example.com) so every App gets its own hostname. A server without it serves Apps in path mode, and channels are not available there.

What you see on a path-mode server:

  • Deploy succeeds with a warning. An App that declares a channels: block or ships a channels/ directory deploys normally, and the deploy response carries a non-fatal partial failure { "phase": "channels", "error": "channels_require_origin_mode" }. npm run deploy prints it under "non-fatal deploy phase failure(s)".
  • __INFORMER__.channel() throws at runtime. The call fails synchronously with an error whose code is origin_mode_required, rather than returning a channel that never delivers.

The page can find out before it calls channel(): window.__INFORMER__.platform.originMode is true on an origin-mode server and false in path mode, and the same platform object reaches server handlers in their bag. Branch on it to fall back to polling a server route, instead of catching origin_mode_required after the fact:

const live = window.__INFORMER__.platform?.originMode === true;
if (live) {
__INFORMER__.channel('orders').on('created', addRow);
} else {
setInterval(refreshOrders, 15000);
}

Ask your Informer administrator whether the server runs in origin mode before building on channels. Everything else in this guide assumes it does.

How it works​

  1. Name a channel: either declare it in informer.yaml under channels: (a relay of existing events) or add a handler file under channels/ (a gated channel with join / leave, and the place inbound messages land). A channel can also be used with neither; see open channels.
  2. Run npm run deploy. Informer validates the channels: block, scans and bundles channels/, and registers each handler file the same way it registers server routes.
  3. On the page, __INFORMER__.channel('orders').on('created', fn) subscribes. The first on() connects the socket lazily; once the subscription is up the channel fires connected.
  4. From any server-side handler (a server route, webhook, agent tool, or channel handler), call broadcast('orders', 'created', payload). Every subscriber receives fn(payload, frame).
  5. To send something back, the page calls channel.send('typing', { at }). The server runs the typing export of the channels/ file that covers the channel and resolves send() with whatever it returned.

Relaying events with channels:​

The quickest way to get live updates is to relay events you already emit. In informer.yaml, map a channel to the events it should carry:

# informer.yaml
events:
order_created:
description: Fired when a new order is submitted
order_shipped:
description: Fired when an order ships

channels:
orders:
description: Live order activity for the Order Desk dashboard
on: [order_created, order_shipped]
payments:
description: Payment confirmations
on: payment_received

Every emit('order_created', payload) from your handlers now does two things: it creates the durable app event exactly as before (agents still trigger), and it also broadcasts a frame to orders with the same event name and payload. Nothing in your handler code changes.

FieldTypeDescription
descriptionstring, optionalAuthor-facing note, at most 500 characters
onstring or non-empty string[], requiredEvent names to relay into this channel. Stored as an array

The block declares relays and nothing else. It is not access control and it does not create a channel: a name that appears nowhere is already subscribable (see open channels), so a channel with nothing to relay needs no entry here. An entry without on (a bare orders: key, or one with only a description, or an empty list) fails the deploy with:

400 Invalid channels: block in informer.yaml: channels.orders: "on" is required — list the events to relay into the channel; a channel with nothing to relay needs no declaration

Rules worth knowing:

  • Channel and event names are validated at deploy. A bad name fails the deploy with 400 Invalid channels: block in informer.yaml: ... rather than silently dropping relays. See names. A wildcard key (rooms/*) passes the deploy but never receives a relay: the server drops every frame relayed to it (relay_dropped in the Logs tab). npm run dev prints an invalid channel name error for it at startup, then serves as usual and drops its relays the same way.
  • A relay that cannot be delivered (the frame is too large, the App is over its broadcast rate) is logged and dropped; the emit() itself still succeeds. Relay never makes an emit fail.
  • Only emit() calls from your handlers are relayed. The onFailure event the platform raises when an agent run fails (see Handling failures) is not.
  • Removing the block from informer.yaml and redeploying removes the relay.

Open channels​

A channel no channels/ handler file covers is open: every page of the App can subscribe (the socket itself is already tied to the App and the tenant), and nothing runs when a page joins or leaves. That is what a relay-only channel is, and it is equally true of a name that appears nowhere at all: broadcast('anything', ...) to a name no file covers reaches whoever subscribed to it. Add a channels/ handler when a channel must be limited to some users, or when pages need to send() on it (an open channel has no handler to receive a message, so send() is refused).

Channel handlers in channels/​

A handler file makes a channel gated: it decides who may join, learns when they leave, can restrict the channel to roles, and receives the messages pages send(). Files under channels/ use the same file-convention routing as server/, and [segment] file or directory names become parameters:

FileChannel patternMatches
channels/orders.jsordersorders
channels/orders/[region].jsorders/:regionorders/east, orders/west
channels/tickets/[id]/typing.jstickets/:id/typingtickets/42/typing

A handler file exports up to four lifecycle names, plus one export per inbound event it accepts:

// channels/orders/[region].js
export const config = {
roles: ['sales', 'manager'], // optional: only these roles may subscribe
timeout: 2000 // optional: wall-clock cap for every export in this file, in ms
};

// Called when a page subscribes. Return exactly `true` to admit it.
export async function join({ channel, request, query, log }) {
const { region } = channel.params;
const [row] = await query(
'SELECT 1 FROM region_access WHERE region = $1 AND username = $2',
[region, request.user.username]
);
log.info('join', { channel: channel.name, user: request.user.username, ok: !!row });
return !!row;
}

// Called a moment after the page is admitted. This broadcast reaches the joiner too.
export async function joined({ channel, request }) {
await channel.broadcast('presence', { joined: request.user.username });
}

// Called when the page unsubscribes, closes, or its socket drops.
export async function leave({ channel, request }) {
await channel.broadcast('presence', { left: request.user.username });
}

// Called when a subscribed page runs channel.send('typing', payload).
export async function typing({ channel, request, payload }) {
await channel.broadcast('typing', { user: request.user.username, at: payload.at });
}
ExportCalledReturn value
configAt deploy{ roles?: string[], timeout?: number }. A config that cannot be parsed fails the deploy, like a route's
joinWhen a socket subscribes to a name this file matches, before it is admittedMust be exactly true to admit. Anything else (including undefined) refuses with join_refused
joinedAfter that socket has been admittedIgnored. A joined that throws never affects the subscription; see Announcing a join
leaveWhen that socket unsubscribes or disconnectsIgnored. A leave that throws is logged and never fails the unsubscribe
<event> (any other name)When a subscribed page calls send(event, payload)Resolves the page's send(); see Inbound messages

Every export is optional, but the file must export something. A file with no join export admits everyone the socket-level checks let through (still subject to config.roles). A file with no leave export does nothing on leave; a file with no export named typing refuses send('typing', ...).

Any export that is not one of the four lifecycle names must be a valid event name, and error and connected are refused because the page-side client dispatches those itself. A default export is not valid. Every problem with a handler file fails the deploy rather than skipping the file: an export that is neither a lifecycle name nor an event name, a reserved event name, no exports at all, a path no page could subscribe to (channels/my orders.js, or channels/index.js, which names no channel), a file that is not valid JavaScript, and two files that map to the same channel pattern. A skipped file would leave its channel with no handler row, and a channel with no row is open, so a typo on redeploy could silently drop the gate the channel had before.

What join sees​

The order of checks when a page subscribes to a gated channel:

  1. Informer confirms the socket belongs to this App and the user can still read the App (a user who lost access gets join_refused).
  2. config.roles, if set: the user must hold at least one listed role, else join_refused.
  3. If the file has no join export, the socket is admitted.
  4. Otherwise join runs, under the same compute budget as a server route, with a wall-clock cap of config.timeout (default 5 s, never above the server's joinTimeoutMs). It must return exactly true.

A join handler receives the shared handler bag (context, query, transaction, fetch, emit, broadcast, notify, email, crypto, markdown, extractText, log, env, platform) plus three channel-specific members, and no respond (there is no HTTP response to send):

MemberTypeDescription
channelobjectchannel.name (the subscribed name, e.g. orders/east), channel.params (from [segment] directories, e.g. { region: 'east' }), and channel.broadcast(event, payload), shorthand for broadcast(channel.name, event, payload)
payloadanynull for join, joined and leave. For an event export, the message the page passed to send() (null when it passed nothing)
requestobjectrequest.user (username, displayName, email, timezone), request.roles (the subscriber's role IDs) and request.member (the page, see below). No body, headers, or query: a subscription is not an HTTP request

request.member names the page the subscriber is on: one browser tab. It is the same for every connection that page makes, reconnects included, and different for another tab, even the same person's. Use it where two tabs of one person must not be mixed up (two players on one laptop, a seat that should survive a dropped connection). It is an opaque string; it says nothing about the user, the server or the socket. An ordinary channel handler runs join and leave for each connection, so a reconnect runs both again; a channel actor runs leave once per page.

joined, leave and every event export receive the same bag. channel.params is the same object that was matched at join time. Every run is capped by the same config.timeout / joinTimeoutMs bound.

Keep join fast

A subscribe is a user waiting for the page to go live, and the cap is 5 seconds. One indexed query is fine; a fetch() to an external service is not. If admission depends on slow data, precompute it into the workspace.

Announcing a join with joined​

join runs before the socket is admitted, so a broadcast from inside join reaches everyone already on the channel but never the page that is joining. If you want the joiner to see its own arrival (a presence list that includes yourself, a "you are connected as Jane" frame), export joined: it runs a moment after join returned true and the subscription is registered, with the same bag, and a broadcast from it reaches the joiner along with everyone else.

joined is a courtesy, not a gate. Its return value is ignored, and if it throws or times out the subscription stands; the failure is logged, counted for the operator as joined_failed, and recorded as a JOINED invocation row. Decide in join, announce in joined:

// channels/rooms/[room].js
export async function join({ channel, request, query }) {
const [seat] = await query('SELECT 1 FROM room_members WHERE room = $1 AND username = $2',
[channel.params.room, request.user.username]);
return !!seat;
}

export async function joined({ channel, request, query }) {
const members = await query('SELECT username FROM room_members WHERE room = $1', [channel.params.room]);
await channel.broadcast('presence', { joined: request.user.username, members: members.map(m => m.username) });
}

Inbound messages with event exports​

A subscribed page can send a message to the App with channel.send(event, payload) (see Sending to the App for the page side). On the server, the message runs the export named after the event in the channels/ file that covers the channel, with the message as payload, and whatever that export returns is what the page's send() resolves with (null when it returned nothing). The run gets the same bag as join, the same compute budget as a server route, and the same config.timeout cap:

// channels/tickets/[id].js
export async function join({ channel, request, query }) {
const [row] = await query('SELECT 1 FROM ticket_access WHERE ticket_id = $1 AND username = $2',
[channel.params.id, request.user.username]);
return !!row;
}

// channel.send('comment', { body }) from any subscribed page
export async function comment({ channel, request, payload, query }) {
const [saved] = await query(
'INSERT INTO comments (ticket_id, author, body) VALUES ($1, $2, $3) RETURNING *',
[channel.params.id, request.user.username, payload.body]
);
await channel.broadcast('comment', saved); // everyone on the ticket, the sender included
return { id: saved.id }; // what send() resolves with
}

// channel.send('typing') carries no payload; nothing is stored
export async function typing({ channel, request }) {
await channel.broadcast('typing', { user: request.user.username });
}

The subscription is the authorization. A page can only send on a channel its socket currently holds, so whatever join and config.roles admitted may send, and nothing else can. There is no separate permission to declare. The sender's identity is request.user, taken from the socket credential, never from the message.

Refusals, as the server answers them and as the page sees them (the page-side code is in Error codes):

Server answerWhenOn the page
403 app_channel_not_subscribedThe socket does not hold the channelsend_refused
404 app_channel_no_handlerThe handler file has no export of that name, the name is one of the lifecycle exports (join, joined, leave, which the relay drives itself), or the channel is open (no file at all)send_refused
400 app_channel_reserved_event / app_channel_invalid_eventThe event is error or connected, or is not a valid event namesend_refused
429 app_channel_rate_limitedThe user is over inboundRate (10 per second, burst 30, per user per App, shared by all of that person's tabs)rate_limited
402The App's compute budget for the period is spentbudget_exhausted
504 app_channel_send_timeoutThe export ran past its caphandler_failed
500 app_channel_send_failedThe export threwhandler_failed

A message body larger than 128 KiB is refused before any handler runs. Every inbound run is recorded as a SEND invocation row under channel:<name>#<event>, counted as Sends · 30d in the App Admin panel's Channels tab, and a thrown export is counted for the operator as send_failed (a rate-limited one as send_rate_limited); see What the App's operator sees.

Broadcasting from the server​

broadcast(channel, event, payload) is in the handler bag of every server-side surface: server routes, webhooks, agent tools, and channel handlers. It resolves to { ok: true, seq } once the frame is published, seq being the frame's position in the channel's sequence (see Sequence numbers and replay); it does not wait for delivery and never reports how many subscribers there were.

A Stripe webhook that confirms a payment and tells every open dashboard:

// webhooks/stripe/payment.js
export async function POST({ crypto, request, env, query, broadcast }) {
// Stripe-Signature: t=<timestamp>,v1=<hex>; the HMAC covers `${t}.${rawBody}`
const header = request.headers['stripe-signature'] || '';
const sig = Object.fromEntries(header.split(',').map(part => part.split('=')));
const ok = sig.t && sig.v1
&& await crypto.verifyHmac('sha256', env.STRIPE_WEBHOOK_SECRET, `${sig.t}.${request.rawBody}`, sig.v1);
if (!ok) return { status: 401, body: { error: 'Invalid signature' } };

const event = request.body;
if (event.type === 'payment_intent.succeeded') {
const intent = event.data.object;
const [payment] = await query(
'INSERT INTO payments (intent_id, amount, currency) VALUES ($1, $2, $3) RETURNING *',
[intent.id, intent.amount, intent.currency]
);
await broadcast('payments', 'received', payment);
}
return { received: true };
}

A server route that broadcasts to a regional channel after an update:

// server/orders/[id]/approve.js
export async function POST({ query, request, broadcast }) {
const [order] = await query(
`UPDATE orders SET status = 'approved', approved_by = $2 WHERE id = $1 RETURNING *`,
[request.params.id, request.user.username]
);
if (!order) return { status: 404, body: { error: 'Not found' } };
await broadcast(`orders/${order.region}`, 'approved', order);
return order;
}

The payload can be any JSON value (or omitted, which sends null). It is serialized once and delivered verbatim; the page receives the same object shape you passed. Keep it small: send the changed row, not the whole table.

Every published frame is also kept for a short while (by default the last 50 frames per channel, for 60 seconds) so a page that drops and reconnects can catch up; see Sequence numbers and replay. A frame that would be stale by the time anyone replayed it, a typing indicator or a cursor position, can stay out of that buffer: pass { replay: false } as the fourth argument, broadcast(channel, event, payload, { replay: false }) (or channel.broadcast(event, payload, { replay: false }) inside a channel handler). It still takes a seq and reaches every live subscriber; it is simply not offered to a page that reconnects.

broadcast() rejects (the call throws) when the frame cannot be published:

ErrorWhy
app_channel_invalid_nameThe channel name does not match the grammar, exceeds 128 characters, or is a wildcard (rooms/*), which pages listen to but nothing broadcasts to
app_channel_invalid_eventThe event name is invalid or exceeds 64 characters
app_channel_reserved_eventThe event is error or connected, which the page-side client dispatches itself
app_channel_invalid_payloadThe payload cannot be serialized to JSON
app_channel_frame_too_largeThe serialized payload exceeds maxFrameBytes (64 KiB by default)
app_channel_rate_limitedThe App is over its broadcast rate (see Limits)
app_compute_budget_exhaustedThe App's compute budget for the period is spent, the same 402 a server route gets
app_channels_unavailableThe App's type does not permit channels, or the server's publish/subscribe layer is not up
app_channels_disabledAn administrator turned channels off (app.channels.enabled: false)
app_channel_broadcast_failedThe publish/subscribe layer refused the frame, so it never left the server and nobody received it

Wrap broadcast() in a try/catch if a lost frame must not fail the request; a broadcast is a courtesy to open pages, not the record of what happened.

The @user/<username> channel​

Channel names beginning with @user/ are private to one user: a socket may subscribe to @user/jane only when the App session belongs to jane. Everyone else is refused with join_refused before any handler runs. Everything after @user/ is the username, all of it, so @user/jane/typing is the channel of a user named jane/typing rather than a second channel belonging to jane. Use it to deliver something to exactly one person's open pages, for example the result of a long-running job they kicked off:

// server/reports/[id]/run.js
export async function POST({ query, request, broadcast, respond }) {
const channel = `@user/${request.user.username}`;
await respond({ status: 202, body: { channel } }); // ack now, keep working
const [report] = await query('SELECT * FROM reports WHERE id = $1', [request.params.id]);
const rows = await query(report.sql);
await broadcast(channel, 'report_ready', { id: report.id, count: rows.length });
}
// on the page: the context carries the viewer's identity
const mine = `@user/${__INFORMER__.user.username}`;
__INFORMER__.channel(mine).on('report_ready', ({ id, count }) => {
showToast(`Report ${id} finished with ${count} rows`);
});
await fetch(`/api/_server/reports/${reportId}/run`, { method: 'POST' });

window.__INFORMER__.user is { username, displayName } for the signed-in viewer (in dev mode it defaults to { username: 'dev', displayName: 'Local Developer' }; set mock.user in vite.config.js to test as someone else). Subscribe before you kick the work off, so a fast result cannot land before the page is listening.

The username may contain anything but whitespace (email-style and domain-qualified usernames work), and it runs to the end of the name. For a second axis (per person and per topic), use a plain channel gated by a channels/ handler instead of a name under @user/. A channels/ handler can still cover @user/... names if you need join or leave logic on top of the ownership check.

Wildcard subscriptions​

A page can subscribe to a whole family of channels at once by ending the name in a * segment: rooms/* receives every frame broadcast to a name beneath rooms/, such as rooms/east and rooms/east/typing. Each frame still carries its concrete channel, so one handler can tell them apart:

__INFORMER__.channel('rooms/*').on('message', (msg, frame) => {
appendTo(frame.channel, msg); // 'rooms/east', 'rooms/west', ...
});

The rules:

  • * may only be the last segment, and only on its own: rooms/* is a wildcard, rooms/*/typing and room* are invalid names, and * alone is not a name. @user/* is not a wildcard either: it names a user called * and is refused as another user's channel.
  • A wildcard is something a page listens to, never a place a frame goes. broadcast('rooms/*', ...) rejects with app_channel_invalid_name, and send() on a wildcard channel is refused on the page with send_refused before anything is sent; send on the concrete channel instead.
  • Gating. The wildcard name resolves through your channels/ files like any other name, with * as the parameter value: subscribing to rooms/* runs channels/rooms/[room].js with channel.params.room === '*', and its join (and config.roles) decides whether this user may listen to every room. A wildcard is admitted only when nothing sits deeper than the handler that resolved it. If a file covers something further down the prefix (channels/rooms/lobby.js, or channels/rooms/[room]/typing.js), the wildcard is refused with join_refused (the server's 403 app_channel_wildcard_gated), because admitting it would hand out that file's frames around its own gate. This holds whether or not the wildcard itself matched a file: * is an ordinary parameter value to the matcher, so rooms/* matches channels/rooms/[room].js and would otherwise run only the shallow file's roles.
  • A wildcard counts as one subscription toward maxChannelsPerSocket, however many concrete channels it hears from.
  • Replay is tracked per concrete channel: after a reconnect, a wildcard subscription catches up on each channel it had already heard from (see the next section).

A join for a wildcard usually looks like "may this user see all rooms?":

// channels/rooms/[room].js
export async function join({ channel, request }) {
if (channel.params.room === '*') return request.roles.includes('moderator');
return await isMember(channel.params.room, request.user.username);
}

Subscribing on the page​

The App's page context exposes __INFORMER__.channel(name). It is synchronous, and nothing connects until the first on().

const orders = __INFORMER__.channel('orders/east');

const off = orders.on('created', (order, frame) => {
// order is the broadcast payload; frame is the whole envelope
addRow(order);
});

orders.on('connected', ({ replayed }) => {
// fires after every (re)subscribe, once any missed frames were replayed
setLive(true);
});

orders.on('error', err => {
console.warn('orders/east', err.code, err.message);
if (err.code === 'replay_gap') refetchOrders();
});

// send a message to the App's handler for this channel
const { id } = await orders.send('comment', { body: 'Looks good' });

// later
off(); // stop this one handler
orders.close(); // unsubscribe the socket and drop every handler
MethodDescription
__INFORMER__.channel(name, opts?)Returns a channel object. Throws synchronously with code: 'join_refused' on a malformed name, or code: 'origin_mode_required' on a path-mode server. opts.since (a seq number) asks for a replay of everything after that frame on the first subscribe
on(event, fn)Registers fn(payload, frame) for frames whose event matches. Returns a function that removes fn. The first on() on a page loads the socket client, fetches /_socket, connects, and subscribes; later channels share the connection
on('connected', fn)Registers fn({ replayed }) for every successful subscribe and re-subscribe, fired once any replay is done. A listener added while the channel is already up hears it once, asynchronously, with { replayed: 0 }
on('error', fn)Registers fn(err) for this channel's errors; err.code is one of the codes below. Without an error handler, errors are logged with console.warn
send(event, payload?)Sends a message to the App over the socket and resolves with the handler's return value (null when it returned nothing). Rejects with code rate_limited, send_refused, budget_exhausted, handler_failed, or disconnected. See Sending to the App
close()Unsubscribes and drops all handlers. Safe to call twice; on() after close() throws, send() after close() rejects with disconnected

A frame is the envelope every subscriber receives:

{
"tenant": "acme",
"appId": "7d5a9b1e-0c83-4bde-9e2a-3a4b5c6d7e8f",
"channel": "orders/east",
"event": "created",
"payload": { "id": 1042, "customer": "Northwind", "total": 1500 },
"seq": 57,
"at": 1724944800000
}

Handlers are called with (payload, frame). An exception inside one handler is logged and does not stop the others. seq is the frame's position in its channel's sequence: contiguous per channel (1, 2, 3 ...), so a page can tell whether anything was sent between two frames it holds.

Sequence numbers and replay​

Every frame carries a seq, a contiguous per-channel counter. The server keeps the most recent frames of each channel for a short window (by default the last 50 frames, for 60 seconds; the replay setting under Limits), and the page client remembers the last seq it delivered on each concrete channel.

After every re-subscribe (the socket dropped and came back), the channel reads what the server still buffers past that seq, over the same socket, delivers those frames in order before any live frame that arrived meanwhile, and drops duplicates. A short disconnect, a laptop lid, a phone coming back from the background: none of them lose the frames sent in between, as long as they fit the window. connected then fires with { replayed: <count> }.

When the window is not enough, the channel says so rather than pretending: if frames were sent while the page was away and the buffer no longer reaches back to the last seq it saw, the channel reports error with code: 'replay_gap' and err.channel naming the concrete channel. That is the signal to re-fetch that channel's state from a server route; live frames keep arriving as normal. A channel whose counter restarted — idle long enough to expire (30 days), or the server's Redis was flushed or failed over — starts again at 1, and the page handles that on its own: it takes everything the buffer still holds rather than measuring the new numbering against the old. The gap check errs toward reporting: a frame sent with { replay: false } takes a seq without being kept, so a page away longer than the window while only such frames flowed hears replay_gap as well, and re-fetching then is the safe answer.

A page that wants to survive a full reload the same way can hand the last seq it saw to channel():

const key = 'orders/east.seq';
const since = Number(sessionStorage.getItem(key)) || undefined;
const orders = __INFORMER__.channel('orders/east', since ? { since } : undefined);
orders.on('created', (order, frame) => {
addRow(order);
sessionStorage.setItem(key, String(frame.seq));
});

With since, the very first subscribe replays everything after that frame (or reports replay_gap when it cannot). Without it, a first subscribe starts from now and replays nothing.

If a replay read itself fails (the server refused it, or the socket dropped in the middle), the channel reports that as an error (send_refused, handler_failed or disconnected) and still comes up on live frames; connected fires with { replayed: 0 }.

The connected event​

on('connected', fn) tells the page when a channel is actually live: after the first subscribe, and again after every reconnect, once any replay is done. Use it to clear a "reconnecting" indicator, or as the moment it is safe to send(). A listener registered on a channel that is already up hears connected once, asynchronously, so it is safe to attach at any time.

connected and error are reserved event names. broadcast() refuses them with app_channel_reserved_event, send() refuses them on the page, and a channels/ file cannot export either.

Sending to the App with send()​

send(event, payload) sends the message to the App over the socket that carries the subscription (never plain HTTP), as a socket message rather than an HTTP-style request, so no request lifecycle runs per message. The server runs the event-named export of the channels/ file covering the channel (see Inbound messages), or calls the channel's actor, and the promise resolves with the export's return value, or null when it returned nothing:

const tickets = __INFORMER__.channel(`tickets/${id}`);
tickets.on('comment', appendComment);
tickets.on('connected', () => sendButton.disabled = false);

sendButton.onclick = async () => {
try {
const { id } = await tickets.send('comment', { body: input.value });
input.value = '';
} catch (err) {
if (err.code === 'rate_limited') showToast('Slow down a little');
else if (err.code === 'send_refused') showToast('You cannot comment here');
else if (err.code === 'handler_failed') showToast('Something went wrong');
else showToast('Reconnecting...'); // disconnected
}
};

What you get back on failure is one of five codes: rate_limited when you are over the per-user inbound rate, send_refused when the server declined the message itself (not subscribed, no export of that name, a reserved or invalid event) or when the page could tell in advance (a reserved or invalid event name, a wildcard channel), budget_exhausted when the App's compute budget is spent, handler_failed when your export threw or timed out, and disconnected when there is no live socket to send on. A send() made before the subscription has landed fails one way or the other (disconnected while the socket is still connecting, send_refused once it is up but the subscribe has not been answered), so wait for connected before the first send. The message is sent as the viewer: the server identifies the sender from the socket, not from anything in the payload.

The server's clock​

__INFORMER__.serverNow() (also channel.serverNow()) is the server's clock in milliseconds, as closely as the page can tell: Date.now() corrected by the offset from the quickest round trip the socket has made. The page samples the server three times when the socket comes up, and again from every send() reply. Use it wherever pages must agree on a moment: a countdown the server starts, a race timer, a deadline. Before the socket is up it returns the page's own clock.

const race = __INFORMER__.channel('race/main');
race.on('countdown', ({ startAt }) => { // startAt: the server's Date.now() when the race starts
const tick = () => {
const left = Math.ceil((startAt - race.serverNow()) / 1000);
banner.textContent = left > 0 ? String(left) : 'GO!';
if (left > 0) requestAnimationFrame(tick);
};
tick();
});

Error codes​

err.codeMeaningWhat to do
join_refusedThe subscription was refused: the channel's join returned something other than true, the user lacks a required role, the name was invalid, the user cannot read the App, a @user/ name belongs to someone else, or a wildcard reaches over a gated channelDo not retry in a loop. Show the page without the live feed, or tell the user why
rate_limitedOn subscribe: this socket already holds maxChannelsPerSocket subscriptions, or the App has reached maxSubscribersPerApp across the whole cluster. On send(), and on the replay reads a reconnect makes (one per concrete channel the handle heard from): the user is over inboundRate. A refused replay read carries err.channel; the other channels still replayClose channels you no longer need; combine channels where possible. The page retries a refused subscription on its own with bounded backoff, so a cap reached during a spike clears without a reload. A rate-limited send() or replay read is not retried; slow down, or narrow a wildcard that hears from more channels than the burst allows
disconnectedThe socket dropped or could not be established. Every open channel receives it once. Also the rejection of a send() made before the channel is up or after close()Nothing is required: the page reconnects on its own with a fresh credential (retrying with bounded backoff: 1 s, doubling to 30 s, and immediately when the tab becomes visible again), re-subscribes every open channel, and replays what was missed. A channel() or on() call reconnects at once too
handler_failedYour handler threw or ran past its timeout — a join, or the export a send() named. This is your App's bug, not a network problem, which is why it is not reported as disconnectedFix the handler. The page does not retry a failed subscribe: each attempt would load the bundle, run the sandbox and bill compute against the App
budget_exhaustedThe App's compute budget for the period is spent, on a subscribe or a send()Nothing the page can do. The subscribe is not retried; the operator raises the budget
replay_gapAfter a reconnect (or a since), frames were sent that the server no longer buffers; err.channel names the concrete channelRe-fetch that channel's state from a server route. Live frames continue
unavailableThe channel's actor is on a server that just went away or stopped answering (its claim lapses within leaseMs), on a subscribe or a send(). A send() refused this way may still have reached the actor if its server stopped answering mid-callNothing required for a subscribe: the page subscribes again on its own with bounded backoff. Retry a send() shortly
send_refusedThe server declined a send(): not subscribed to the channel, no export of that name (including one named after config, join, joined or leave), or a reserved or invalid event. Also returned without a round trip for a reserved or invalid event name or a wildcard channelFix the event name or the handler file; do not retry the same message
origin_mode_requiredThrown by channel() itself on a path-mode serverCheck __INFORMER__.platform.originMode first and fall back to polling a server route, or ask the administrator about origin mode
channels_disabledThrown by channel() itself when an administrator has turned channels off for the whole serverSame fallback as above. Nothing the App does will change it

React example​

A small hook keeps the subscription tied to the component lifecycle, and lets the component re-fetch when a reconnect could not be replayed:

import { useEffect, useState } from 'react';

function useChannel(name, event, onFrame, onGap) {
useEffect(() => {
if (!window.__INFORMER__.platform?.originMode) {
console.warn('Live updates unavailable: path mode');
return undefined;
}
const channel = window.__INFORMER__.channel(name);
channel.on(event, onFrame);
channel.on('error', err => {
if (err.code === 'replay_gap' && onGap) onGap();
else console.warn(`${name}: ${err.code}`);
});
return () => channel.close();
}, [name, event, onFrame, onGap]);
}

export function OrderFeed({ region }) {
const [orders, setOrders] = useState([]);

const load = () => fetch('/api/_server/orders?region=' + region)
.then(res => res.json())
.then(setOrders);

useEffect(() => { load(); }, [region]);

useChannel(`orders/${region}`, 'created', order => {
setOrders(current => [order, ...current]);
}, load);

return (
<ul>
{orders.map(order => (
<li key={order.id}>{order.customer}: {order.total}</li>
))}
</ul>
);
}

Load the current state from a server route first, then let the channel keep it fresh. Frames sent during a short reconnect are replayed on their own; only a replay_gap means something was missed, and that is when the page re-fetches.

broadcast() versus emit()​

The two verbs look alike and are meant for different jobs:

emit(event, payload)broadcast(channel, event, payload)
Writes a rowYes, an app event that agents process and you can inspect laterNo
DeliveryDurable: retried, dead-lettered, visible in run historyAt most once: delivered to whoever is subscribed right now; lost if nobody is. A page that reconnects inside the replay window (60 s, 50 frames by default) catches up; past it, it is told there was a gap
AudienceAgents (and, through channels:, subscribers)Open pages
OrderingProcessed asynchronouslyFrames on one channel carry a contiguous seq; the page delivers each frame once
CostA database write per callA Redis publish per call, rate-limited per App
The rule of thumb

If you would be upset that it was lost, emit. If it would be stale in a second anyway, broadcast. An approved order is a fact: emit('order_approved') and let the channels: relay tell the dashboards. "Jane is typing" is a moment: broadcast('tickets/42/typing', 'typing', ...) and forget it.

A page that needs both the fact and the live update can emit() and rely on the relay, so the durable record and the live frame come from one call.

Channel and event names​

GrammarMax lengthExamples
ChannelSegments of letters, digits, _, ., -, joined by /. A subscription may end in one extra * segment (a wildcard). A name beginning @user/ is the exception: everything after that prefix is one username, and may hold any character except whitespace128orders, orders/east, tickets/42/typing, rooms/*, @user/jane@acme.com
EventLetters, digits, _, ., -64created, order_created, presence

The same grammar applies in informer.yaml, in broadcast(), in send(), in handler export names, and on the page. Two event names are reserved: error (channel errors on the page) and connected (the subscription is up). Neither can be broadcast, sent, or exported from a channels/ file; use failed or problem for your own error frames.

Limits​

Defaults set on the Informer server under app.channels. An administrator can change them; an App cannot.

SettingDefaultEnforced when
enabledtruefalse turns the feature off outright: pages get a channel() that throws channels_disabled, no socket credential is issued, every broadcast() rejects with app_channels_disabled, and the App admin panel's Channels tab says so instead of showing zeros
maxSubscribersPerApp500Subscriptions per App, summed across the whole cluster (each server tallies the subscriptions it holds, and reads add up the live servers). The next subscribe gets rate_limited
maxChannelsPerSocket20Subscriptions one page's socket may hold; a wildcard counts as one. The next on() on a new channel gets rate_limited
maxFrameBytes65536 (64 KiB)Serialized payload size per broadcast; larger rejects with app_channel_frame_too_large
broadcastRate50 per second, burst 200A per-App token bucket shared across the cluster; exceeding it rejects with app_channel_rate_limited
inboundRate10 per second, burst 30A per-user, per-App token bucket for send() and for the replay reads a reconnect makes; exceeding it rejects with rate_limited
replayframes: 50, ttlMs: 60000How many frames each channel keeps for reconnecting pages, and for how long
joinTimeoutMs5000Upper bound for any channel handler run (join, joined, leave, and event exports), whatever config.timeout says
actors.enabledtruefalse turns channel actors off; platform.capabilities.channelActors reads false
actors.maxPerApp, actors.maxPerServer20, 200Live actors per App across the whole cluster, and in all on one server. The next subscribe that would start one gets rate_limited
actors.maxTickHz60Ceiling on config.actor.tick
actors.memoryMb, actors.maxMemoryMb32, 128An actor's sandbox heap: the default, and the most config.actor.memoryMb may ask for
actors.idleMs, actors.maxIdleMs30000, 300000How long an actor outlives its last subscriber: the default, and the most config.actor.idleMs may ask for
actors.maxLifetimeMs14400000 (4 h)An actor is stopped (through its stop()) after this long; the next message starts a fresh one
actors.callTimeoutMs1000One call into an actor; past it the actor is stopped as hung
actors.stopTimeoutMs5000How long an actor's stop() may run
actors.idleCreditsPerGbHour0.25What an actor's waiting costs, in credits per GB-hour of the memory it holds (its memoryMb plus the isolate's overhead). Its working time is billed as app compute
actors.meterEveryMs10000How often an actor's compute is recorded and the budget checked
actors.leaseMs15000The Redis lease naming the one server that runs an actor
actors.inboundRate30 per second, burst 60The per-user send() bucket for actor channels, kept apart from inboundRate
actors.broadcastRate120 per second, burst 240Each actor's allowance for broadcasts to its own channel, kept apart from broadcastRate

Channels are an App capability: a Magic Report deploy that carries a channels: block or a channels/ directory is refused with apps_license_required.

Local development​

npm run dev gives you the whole loop without a server socket:

  • broadcast() is in the dev handler bag for server routes, agent tools, and channel handlers, with the same name, event, payload, reserved-event, and frame-size validation as production (and the same error codes). Each call is logged to the terminal. Frames carry a per-channel seq exactly as in production, and the dev broadcast() resolves to { ok: true, seq } as production does.
  • emit() runs the channels: relay locally (no app event row is written in dev), so a page subscribed to a relayed channel sees the frame.
  • channels/ handlers run locally. A subscribe runs the covering file's join (with config.roles checked against mock.roles in vite.config.js), then joined; an unsubscribe runs leave; send() runs the event-named export and resolves with its return value, metered per user at the production default (10 per second, burst 30). request.user is mock.user and request.roles is mock.roles. The @user/ ownership check is enforced against mock.user, and wildcard gating works the same way as on the server.
  • Channel actors run locally too (plugin 2.13.0 and later). A config.actor file gets one module instance per concrete channel, with its calls one at a time, tick() on a timer and the idle stop, and request.member names each page. A page that closes or reloads leaves its channels, so its leave runs and an actor it was the last page of goes idle. snapshot() is kept in memory: edit a file under channels/, shared/, lib/ or server/ and the running actors restart on the new code from their snapshots, with the same pages subscribed. __INFORMER__.serverNow() is the page's own clock, which on the dev server is the server's. The cluster, leases, billing and broadcast limits exist only on a server.
  • __INFORMER__.channel() on the dev page has the production surface: on(), on('connected'), send(), close(), since, replay after a dropped dev socket, replay_gap, and wildcards. Frames ride Vite's own dev WebSocket, so there is no second socket and nothing to configure. frame.tenant is 'dev' and frame.appId is the mocked App id.
  • __INFORMER__.platform.originMode is true in dev; the dev server behaves like an App on its own origin. Override it with mock.platform to exercise your path-mode fallback.
  • informer.yaml and channels/ are validated when the dev server starts: an entry without on, a bad channel or event name, a stray or reserved export, or two files on one channel print as [informer] ... so you see at boot what the deploy would reject.

Dev mode needs INFORMER_URL set in .env (the plugin mounts nothing without it), but the server does not have to be reachable for broadcast(), the relay, and the channel handlers to reach the page. The dev identity is __INFORMER__.user on the page and request.user in handlers, the same object, so @user/${request.user.username} lands where the page is listening.

What dev does not do: there is no compute budget, no per-App broadcast rate, and no enabled switch, so broadcast() never rejects with app_channel_rate_limited, app_compute_budget_exhausted, or app_channels_disabled, and a subscribe never sees the subscriber or per-socket caps. Every page served by the dev server is the same mocked user, so two browser tabs cannot stand in for two people. The mock only works on a page served by vite dev with its dev WebSocket available; without it, the first on() reports disconnected.

No timers in the sandbox​

Handlers have no setTimeout/setInterval, so a "tick every second" loop cannot live in a route: a loop of broadcast() calls runs back to back and the per-app rate limit applies to the burst. For a steady cadence on a channel, declare a channel actor with config.actor.tick: the server calls its tick() for you. For anything slower, drive periodic broadcasts from a scheduled agent, a webhook, or the page's own timer calling a route.

Channel actors​

A channels/ file normally runs each export in a fresh sandbox: every join, leave and send() starts from nothing, and nothing survives between messages. A file that declares config.actor gets one long-lived sandbox per concrete channel instead (match/42, match/43), kept while the channel has subscribers. Module-level variables are the channel's state; send() is a call into the running sandbox rather than a new one; and the server can call a tick() export at a fixed rate. That is what a live game, a shared whiteboard or a presence roster needs.

// channels/match/[id].js
export const config = { actor: { tick: 30, idleMs: 30000 } };

let state; // lives as long as the actor

export function start({ channel }) { // once, before anything else
state = { id: channel.params.id, players: {}, ball: { x: 0, y: 0 } };
}

export function joined({ request }) {
state.players[request.member] ??= { name: request.user.displayName, score: 0 };
}

export function leave({ request }) { // once this page's last connection is gone
delete state.players[request.member];
}

export function input({ payload, request }) { // page: channel.send('input', { dy: 1 })
state.players[request.member].dy = payload.dy;
}

export async function tick({ channel, dt, frame }) { // 30 times a second
step(state, dt);
await channel.broadcast('state', snapshot(state), { replay: false });
}

export async function stop({ reason, query }) { // on the way out
await query('INSERT INTO results (match, players) VALUES ($1, $2)', [state.id, JSON.stringify(state.players)]);
}
config.actorDefaultMeaning
ticknoneCalls per second to tick({ dt, frame }), up to the server's maxTickHz (60). A file with a rate must export tick, and one that exports tick must give a rate
idleMs30000How long the actor outlives its last subscriber
memoryMb32The sandbox's heap, up to the server's maxMemoryMb
snapshotMs5000How often to call snapshot(), from 1000 to the server's maxSnapshotMs (60000). Only for a file that exports snapshot

Exports. start, tick, stop and snapshot are called by the server; a page cannot send() them. join, joined and leave run inside the actor, so it sees every arrival and departure, and every other export is an event a page can send. Calls run one at a time, in arrival order: the handler never sees two messages interleave. At most actors.maxQueue (64) of them wait behind the one running; past that a page's send() is refused as unavailable (retryable), and a call that waited longer than its caller would is dropped unrun. tick receives dt (seconds since the last tick) and frame (its number); a tick that comes due while the actor is still busy with another call is skipped, not queued, so a slow tick costs a frame rather than a growing backlog.

Members. An actor counts connections, but it hears about pages. join and joined run for every connection, so a page that reconnects runs them again (treat them as idempotent, keyed by request.member). An actor never holds a member its own join did not admit: a page whose actor restarted (idle, a redeploy, a crash) is put through join again by its next send(), without the page doing anything, and a send() that join refuses fails as send_refused. A join that refuses the only page on a fresh actor stops it within a second, so refused joins do not hold the App's actor slots. leave runs once, when that page's last connection is gone, including a page whose server went away and never said goodbye. A page's reconnect therefore never looks like a departure followed by a new arrival.

The bag is the channel bag without fetch and typed context dependencies: those run with one user's credentials, and an actor serves every subscriber. query() works; the actor opens a workspace connection when it needs one and closes it when it goes quiet. request is the sender for join, joined, leave and events, and null for start, tick, stop and snapshot.

Broadcasts to the actor's own channel are published in order, off the call that made them, so a tick that streams state does not wait on the network. broadcast() answers once the frame is queued, as { ok: true, queued: true, seq: null }; the frame still takes the channel's next seq, so replay and replay_gap work as on any channel. They draw on the actor's own allowance (actors.broadcastRate), not the App's; a broadcast to any other channel draws on the App's as usual.

Lifecycle. The first join starts the actor (start() runs before the join does). It stops idleMs after its last subscriber leaves, after maxLifetimeMs, when the App is redeployed, and when the server shuts down, and in each case its stop({ reason }) runs first, so it can save what it must. It is stopped at once, without stop(), when a call runs past callTimeoutMs (the caller gets handler_failed), when work an export started and did not await keeps the sandbox busy for that long between calls, or when the sandbox runs out of memory; the reason appears in the App's Logs tab. What a call returns to a page is limited to 128 KB of JSON (actors.maxResultBytes); more is refused as send_refused and logged. The next join or send() starts a fresh actor on the current deploy, with fresh state, unless the actor keeps snapshots.

One server, reachable from all of them. Each actor runs on exactly one server, which holds a short lease on it in Redis; the first join starts it on the server that join reached. Joins, leaves and send()s that reach any other server are carried to it over Redis and answered back, about one Redis round trip each way, so a page can be connected anywhere. Its frames reach every server's pages as any broadcast does. A server that dies loses its actors: until its lease lapses (leaseMs), a subscribe or send() for one of them answers unavailable and the page retries. After that a new actor starts on its own, within seconds, wherever pages are still subscribed, so a channel whose pages only listen does not stay silent; it starts from the last snapshot if the file keeps them (from the file's initial state if not), and each page still subscribed runs join and joined again. Actors a live server stopped on purpose (idle, a spent budget) stay stopped until a page sends or joins. The members the dead server held are dropped from actors elsewhere once its heartbeat stops.

Billing. The sandbox's working time is app compute, billed like any handler's time: the time inside calls, or the sandbox's own CPU time when work carried on after a call returned, whichever is more. Time an actor spends waiting between calls costs only the memory it holds, so it is billed by the GB-hour of its memoryMb (actors.idleCreditsPerGbHour): a 32 MB actor waiting for an hour costs a small fraction of a credit, so a smaller memoryMb is cheaper to keep open. Both are recorded every few seconds (the Usage tab shows them as ACTOR and ACTOR_IDLE), the budget is checked at the same moment, and an App out of compute has its actors stopped. The App's request list shows one row per actor run, with how long it lived and why it ended.

Check __INFORMER__.platform.capabilities.channelActors before relying on actors; it is false on a server that predates them or has them switched off, and there the same file runs as an ordinary channel handler.

Snapshots​

An actor's state lives in its sandbox's memory, so a redeploy or a server that stops takes it away. Export snapshot() to keep it: return what a new actor would need to carry on (plain JSON, up to 256 KB), and the next start() receives it as restored.

export const config = { actor: { tick: 30, snapshotMs: 5000 } };

let state;

export function start({ channel, restored }) {
state = restored ?? { id: channel.params.id, players: {}, ball: { x: 0, y: 0 } };
}

export function snapshot() {
return state;
}

snapshot() is called every snapshotMs, between messages like any call, and once more just before a stop the actor should come back from:

What happensThe actor
The App is redeployedKeeps a snapshot, runs stop({ reason: 'redeploy' }), and if it has members starts again at once on the new code from that snapshot. Its members stay subscribed and are carried in, each through the new code's config.roles and join; one the new code refuses is out until it subscribes again
The server shuts downKeeps a snapshot and stops; the pages reconnect elsewhere and the first join there starts it from the snapshot
Its server diesNothing runs; the actor that takes over (see below) starts from the last periodic snapshot, at most snapshotMs old
It reaches maxLifetimeMsKeeps a snapshot; the next join or send() starts it from there
It goes idle, or the App's budget runs outIts snapshot is dropped: the next start is fresh
A call hangs or the sandbox crashesIts snapshot is dropped, since the state may be what broke it
Its compute could not be recorded for a minuteKeeps a snapshot and stops, rather than run on unmetered; the next join or send() starts it from there

Snapshots are kept for 15 minutes. A new deploy's start() receives the old deploy's state, so read it defensively when its shape changes. When start() throws with a restored state, the snapshot is dropped so the start after it is fresh. After a server shuts down or dies, pages that are still subscribed join again, so join and joined can run for a member the restored state already holds: key them by request.member. snapshot() calls are billed as compute like any other call.

What the App's operator sees​

A channel is far too chatty to log per event, so every refusal and drop the platform makes on the App's behalf is counted per (code, channel) and written into the App's own log stream once a minute: one warn row per code, naming the three busiest channels, such as 14 relays dropped on "orders" in the last minute. The rows appear under the Logs tab of the App Admin panel with a channel source chip.

This is the only place an author sees a broadcast() that failed after the call returned, and the only place an administrator sees pages being refused at a cap.

CodeThe row readsUsually means
join_refusedN joins refused by the join handlerThe channel's join returned something other than true
join_role_requiredN joins refused for a missing roleThe subscriber holds none of the channel's config.roles
join_timeoutN join handlers timed outjoin ran past joinTimeoutMs
join_failedN join handlers failedjoin threw
join_invalid_nameN joins refused for an unreadable channel nameThe name could not be decoded, so neither the handler nor the role gate could be matched against it
join_wildcard_gatedN wildcard subscriptions refused: a handler gates channels under the prefixA page subscribed to something/* while a channels/ file gates a channel beneath it and no file covers the wildcard itself
joined_failedN joined handlers failedjoined threw or timed out; the subscriptions stand
send_rate_limitedN inbound messages rate-limitedOne user's send() calls exceeded inboundRate
replay_rate_limitedN replay reads rate-limitedOne user's replay reads after a reconnect exceeded inboundRate, which they share with send()
send_failedN inbound handlers failedAn event export threw or timed out
subscriber_limitN subscriptions refused at the app subscriber capThe App is at maxSubscribersPerApp across the cluster
socket_limitN subscriptions refused at the per-socket channel capOne page asked for more than maxChannelsPerSocket
actor_limitN subscriptions refused at the channel actor capStarting another actor would pass actors.maxPerApp or actors.maxPerServer
broadcast_rate_limitedN broadcasts rate-limitedThe App is over its broadcastRate
broadcast_failedN broadcasts lost before leaving the serverThe publish/subscribe layer refused the frame; nobody received it
budget_exhaustedN broadcasts dropped with the compute budget exhaustedThe App's compute budget for the period is spent
budget_check_failedN broadcasts allowed without a compute budget checkThe budget could not be read, so the frame was let through rather than lost
relay_droppedN relays droppedAn emit() could not be relayed to its channel

The Channels tab of the same panel carries the live counters these drops sit beside: subscribers right now, broadcasts, deliveries and rate-limited frames over the last hour, and joins, refusals, leaves and sends over the last 30 days. See Live channels in the Help Center.

Troubleshooting​

  • channel() throws origin_mode_required. The server serves Apps in path mode. Nothing in the App can change that; check __INFORMER__.platform.originMode before calling channel() and see Channels need origin mode.
  • Deploy printed channels_require_origin_mode. Same cause, seen at deploy time. The App deployed; its channels will not connect until the server runs in origin mode.
  • Deploy failed with "on" is required. An entry in the channels: block has no events to relay. Either list them under on, or delete the entry: a channel with nothing to relay needs no declaration, and is subscribable without one.
  • join_refused with a handler that returns a truthy value. join must return exactly true. return row or return 1 refuses.
  • join_refused and join never ran. Check config.roles (the subscriber needs one of them), the @user/ owner, and whether the user can still open the App. For a wildcard name, check whether a handler file gates a channel beneath the prefix without covering the wildcard itself (the Logs tab shows join_wildcard_gated). Look at the App's Logs tab in the App Admin panel for join output.
  • I never see my own join. join runs before the socket is admitted, so a broadcast from inside it reaches everyone but the joiner. Broadcast from joined instead; see Announcing a join with joined.
  • send() rejects with send_refused. In order of likelihood: the page is not subscribed to that channel (subscribe and wait for connected first); the channels/ file covering the channel has no export named after the event, or the channel is open and has no file at all; the event name is error, connected, or not a valid event name; the channel is a wildcard. The App's Logs tab shows send_failed when the export ran and threw, which surfaces on the page as disconnected rather than send_refused.
  • send() rejects (disconnected or send_refused) right after the first on(). The subscription has not landed yet. Send from a connected listener, or after it has fired.
  • Subscribed, but frames never arrive. Confirm the page subscribed to the exact name the server broadcasts to (orders/east is not orders), that the event names match, and that the relay block lists the event you emit(). A frame larger than 64 KiB or one sent while the App is over its rate is rejected by broadcast(); a relay that hits the same limits is dropped and counted in the App's Logs tab under the channel source as relay_dropped (see What the App's operator sees).
  • replay_gap after every reconnect. The channel is busier than the replay window (50 frames, 60 seconds by default). Re-fetch on the gap as the error intends, or move the high-volume frames to their own channel so the one that matters stays within the window.
  • disconnected right after subscribing. A join that exceeded its timeout surfaces this way (the server answers 504). Make join faster, or raise config.timeout up to the server's joinTimeoutMs.
  • Works in dev, refused after deploy. Dev runs your handlers as mock.user with mock.roles; a deployed App sees the real viewer and the roles they were actually granted. Check config.roles against the person's roles and whether they can open the App. Dev also has no compute budget and no broadcast rate limit.
  • app_channels_unavailable from broadcast(). The App's type does not include channels (a Magic Report, for instance), or the server's publish/subscribe layer is down.

Project structure with channels​

my-app/
channels/
orders/
[region].js → orders/:region (config, join, joined, leave)
tickets/
[id].js → tickets/:id (join; `comment` and `typing` exports answer send())
server/
orders/
[id]/
approve.js → POST /orders/:id/approve, broadcasts to orders/:region
webhooks/
stripe/
payment.js → broadcasts to payments
informer.yaml → channels: relay block (each entry needs `on`)
index.html
package.json