Besides messages, a room carries the liveness features a chat UI needs: a typing indicator for “Alice is typing”, ephemeral reactions, and presence with derived member counts. All of them ride the room’s channel, so they need no extra setup.
Typing indicators
Call keystroke() from your input’s change handler and subscribe to render who’s typing:
input.addEventListener('input', () => {
room.typing.keystroke();
});
room.typing.subscribe((event) => {
showTyping([...event.currentlyTyping]); // client ids, excluding yourself
});
You can call keystroke() on literally every keystroke. The SDK broadcasts a typing signal at most once per heartbeat window (10 seconds by default) and repeats it while typing continues, so the channel sees a trickle, not your keyboard. Call room.typing.stop() when the user sends the message or clears the input, which broadcasts the stop immediately.
On the receiving side a typist expires automatically 2 seconds after their heartbeat window passes with no signal, so a closed laptop never types forever. The typing indicator never includes the local client, and room.typing.currentlyTyping gives the current set without subscribing.
The heartbeat window is configurable per room with chat.rooms.get(name, { typing: { heartbeatThrottleMs } }). Every client in a room must use the same value, since receivers derive expiry from it.
Reactions
Room reactions are fire-and-forget signals to everyone in the room, the “🎉 during a livestream” feature:
await room.reactions.send({ name: '🎉' });
room.reactions.subscribe((reaction) => {
burst(reaction.name, reaction.isSelf);
});
A reaction carries its name, the sender’s clientId, optional metadata, and isSelf so you can style your own differently. Reactions are ephemeral: they are never stored, never appear in history, and a client that joins a second later never sees them. Reactions attached to a specific message aren’t part of this API.
Presence
Room presence is channel presence scoped to the room, with a local snapshot:
await room.presence.enter({ name: 'Acorn' });
room.presence.subscribe((event) => {
console.log(event.type, event.member.clientId); // enter, update, or leave
});
room.presence.get(); // Current members, served locally
room.presence.isUserPresent('bob'); // true while any of bob's devices are in
Members are tracked per connection, so one user on two devices is two members with the same clientId. The snapshot is maintained from live events, so get() never makes a network call.
Occupancy
Occupancy turns the presence set into two numbers, debounced so a burst of joins repaints your counter once:
room.occupancy.subscribe(({ connections, presenceMembers }) => {
setCounter(`${presenceMembers} online`);
});
connections counts devices and presenceMembers counts distinct users. Only members who entered presence are counted, so a lurker who subscribed without entering isn’t in either number. The debounce defaults to 1 second and is configurable with chat.rooms.get(name, { occupancy: { debounceMs } }).
Room lifecycle
room.status mirrors the underlying channel state, and room.onStatusChange watches it. After a reconnect that lost messages beyond what could be replayed, room.onDiscontinuity fires so you can reload the backlog:
room.onDiscontinuity(() => {
refreshFromHistory();
});
Capabilities
Typing and reactions publish and subscribe on the room’s channel, and presence needs the presence operation. A full chat user typically holds ["subscribe", "publish", "presence", "history"] on its rooms. See Authentication and capabilities.