Messaging
Subscribe to a segment, exchange byte payloads, and understand what publish completion means.
Before you start: use the client and unconnected channel setup from Connection and lifecycle. Your credentials must grant read/write access to chat.
A channel connection automatically joins default. Steps 1 and 2 use an additional named segment, chat, so they explicitly subscribe. To use default directly, skip to step 3.
1. Register a listener and subscribe
In web/app.js, register these interests before the current Connect button calls connect(). The final line below shows the connection step. Do not add a second simultaneous call.
import { readText, textPayload } from "@useceleris/client";
const chat = channel.segment("chat");
const stopListening = chat.onMessage((payload, metadata) => {
console.log(metadata.tokenReference, readText(payload));
console.log("Message ID:", metadata.messageId);
});
const membership = chat.subscribe();
await channel.connect();
onMessage() registers a local callback. subscribe() records interest in the segment. A call to only onMessage() does not join chat. The default segment is different because the channel connection already joins it.
The listener receives payload bytes first, then metadata. metadata.tokenReference is the reference claim in the token of the sender. Presence events and presence lists show the same value as tokenReference. Multiple handles for the same segment share listener sets and reference-counted interest.
A channel is one WebSocket connection. A segment is not a connection. When you connect a channel, the SDK opens a new connection. The SDK also opens a new connection for a channel reference that a different channel uses. All segments of a channel use the connection of that channel, and channel.segment() does not open a connection. To divide your messages into groups, use more segments on one channel, not more channels.
2. Publish bytes
await chat.publish({ payload: textPayload("Hello from this connection") });
Open a second connected subscriber to observe receipt. By default, a connection does not receive its own publish. Your server can opt into echo with allowEcho: true when it signs credentials.
A resolved publish means only that the local socket accepted the bytes. A server permission denial or size rejection can arrive later through channel.events().onError. There is no per-publish receipt.
Subscribe to receive
A publish does not join the connection to a segment. To receive messages from a named segment, call subscribe(). A listener alone does not receive messages.
This rule also applies to echo. With allowEcho: true, the connection receives its own publish only while it holds a subscription to that segment.
Segment permissions
The server applies the permissions in your credentials to each segment. Read access is necessary to subscribe and to use presence. Write access is necessary to publish. For the full rules, see Permissions.
- On a read-only segment, the connection can subscribe and receive messages, but the server refuses a publish.
- On a write-only segment, the connection can publish, but the server refuses a subscription.
- On a segment that is not in the claims, the server refuses
subscribe(),publish()and presence.
Each refusal is a PermissionDeniedError that names the command and the segment. It goes to channel.events().onError, but a refused presence query rejects its presenceList() call. The connection stays open.
For examples of write-only access, see When to Use Write-Only Permissions.
Listen to all segments of the channel
A segment listener receives the messages of one segment. channel.events().onMessage() receives all messages that the connection gets, from all segments. These segments are default and the segments that you subscribe to. The listener also receives messages from segments that have no segment listener. If your code sends each message to the correct handler, use this listener. One channel listener can replace one listener for each segment.
const removeChannelListener = channel
.events()
.onMessage((payload, metadata) => {
console.log(metadata.segmentId, readText(payload));
});
The channel listener gets the same payload and metadata as the segment listeners, after the segment listeners. It gets each message ID one time. All listeners of a message get the same bytes. Do not change these bytes.
onMessage() returns a function that removes only this channel listener. When you call removeChannelListener():
- The other channel listeners continue to receive messages.
- The segment listeners continue to receive messages.
- Your subscriptions do not change, and the SDK does not send a message to the server.
When you call the function again, it has no effect.
Optional message identifiers
await chat.publish({
messageId: crypto.randomUUID(),
payload: textPayload("An update with an application-supplied ID"),
});
If you omit the identifier, the SDK generates one: msg__js_v, the SDK version, two underscores, and 32 random lowercase hex digits, for example msg__js_v1.1.0__4f1c9a0b7d2e43f6a8b5c1d0e9f27364. A generated ID has only letters, digits, dots, and underscores. The double underscores keep it different from the IDs that the server generates (msg_{node}_…). A receiver can then recognize the publish if the SDK resends it. A reused ID does not guarantee an exactly-once publish. The client filters repeated IDs only within a window of the last deduplicationWindowSize IDs for each channel (1024 by default). Use your own durable idempotency for business operations. Your own ID must have at least one character and must not contain CR or LF. The SDK sends it as you give it.
Size and disconnected behavior
The SDK rejects an encoded command larger than 2 MiB and does not write it. Framing, identifiers, and payload together count toward that bound.
The server enforces a lower limit set by your plan. That limit also counts the full encoded command: the framing, the identifiers, and the payload. See Limits for current values. A plan-limit rejection arrives asynchronously and still counts toward usage. Keep the payload below the limit, with space for the framing and the identifiers. Do not use a rejection as a size probe.
The SDK accepts a publish while the channel is connected or reconnecting. The publish goes into the publish queue and waits there until the socket writer has space. The queue holds at most publishQueueSize publishes (64 by default). When the queue is full, a new publish rejects at once with Backpressure. If the signal of a queued publish aborts, the SDK removes the publish, and it rejects with Cancelled.
If the connection closes, a publish that the SDK did not give to the socket stays in the queue. While the channel is reconnecting, a new publish also goes into the queue, and the Backpressure limit still applies. After the reconnect, the SDK first sends your message subscriptions again, then your presence subscriptions. Then it sends the queued publishes in the sequence of the calls. Each publish resolves when the SDK sends it.
The SDK does not send a publish again if it gave that publish to the socket before the connection closed. Only rate-limit recovery sends a publish again. When recovery stops in failed, the queued publishes reject with the error that stopped recovery. When you close the channel, they reject with Cancelled. A publish before the first connect, during the first connect, after failed, or after close() rejects with NotConnected. The queue is in memory only, and there is no durable offline queue.
A DeliveryUnknown error means that acceptance is uncertain. A blind retry can duplicate the action.
When the server rate-limits the connection, its RateLimitError does not say which command it dropped. The SDK then pauses all sends for at least one second. After the pause, the SDK resends what it sent in the last two seconds:
- Subscription changes go first.
- Up to the last 64 publishes follow, each at most once and with its original ID.
A receiver drops a resent copy that arrived earlier. Resends count toward usage.
After eight rate limits in a row, the SDK treats the limit as a used-up quota and stops the resends. It retries the dropped subscriptions after one minute. That delay doubles each time, to at most one hour, until commands go through without a limit again. If rate limits continue, publish less often.
3. Use the default segment deliberately
const defaultSegment = channel.defaultSegment(); // Same as segment("default").
You are already a member of default when the channel connects. This handle accesses that membership. It does not create a new socket or require subscribe(). Use it when one stream per channel is enough.
To try it with the Quickstart, make these changes in both tabs' shared application:
- In
server.mjs, replace thechatsegment permission with{ segmentId: "default", read: true, write: true }. Keep the channel restricted todemo-room, and restart the credential server. - In
web/app.js, replaceconst chat = channel.segment("chat");with the line below. When you keep the variable name, the current publish and listener code stays unchanged.
const chat = channel.defaultSegment();
- Remove
const membership = chat.subscribe();and its pairedmembership.cancel();cleanup call. Keepchat.onMessage(), its disposer, andchannel.close(). - Reload both tabs to obtain the new permissions, connect them, and send a message. The other tab receives the message through
default. Neither tab callssubscribe().
The final message setup is:
const channel = client.channel("demo-room");
const chat = channel.defaultSegment();
const stopMessages = chat.onMessage((payload, metadata) => {
log(metadata.tokenReference + ": " + readText(payload));
});
// The existing Connect button calls channel.connect().
// The existing Send handler calls chat.publish({ payload: textPayload(message) }).
Automatic membership does not bypass authorization. Your credentials still need read permission to receive and write permission to publish. The unchanged Quickstart grants only chat, which is why the server-side permission change above is necessary.
default is a stream, not a wildcard. It does not receive messages published to chat or to other named segments. Sender echo still follows allowEcho, just as it does on any other segment.
There is no remote unsubscribe for the default segment. Automatic membership does not subscribe to presence.
4. Clean up subscriptions and listeners
For the named-segment example in steps 1 and 2:
stopListening();
membership.cancel();
await channel.close();
For the default-segment Quickstart variation in step 3, keep the current stopMessages() and channel.close() cleanup. There is no subscription handle to cancel. A channel close leaves default.
Listener removal and subscription cancellation are different actions. When one caller cancels a subscription, the subscriptions of other callers stay. On a segment that is not default, the SDK tells the server to unsubscribe when you cancel the last message subscription. A presence subscription does not keep the segment. To remove a channel listener, call the function that events().onMessage() returns, for example removeChannelListener().
After the send, the second tab receives the text once. The sender sees no received copy unless your server enables echo. Cleanup stops this receiver and closes its channel.
The SDK restores held interests after a reconnect. Then it sends the queued publishes. It does not send again a publish that it gave to the socket before the connection closed. Read Reconnection and Recovery.
Next: Payload formats.