Client realtime
Durable message reconciliation and ephemeral event subscriptions.
const unsubscribe = chatClient.realtime.subscribe((event) => {
if (event.type === "message.created") console.log(event.message.body);
});
chatClient.realtime.connect();
unsubscribe();realtime owns one lazy EventSource. Native browser reconnect behavior sends
Last-Event-ID; the client does not implement a second cursor protocol.
Durable messages are reconciled by id and sequence and remain newest-first.
message.created, message.updated, message.deleted, reaction.added,
reaction.removed, participant.added, participant.removed, and
conversation.updated all update the cache.
Membership events
The three conversation events (ADR 0017) carry the full post-change conversation and merge it into the cached list and single-conversation queries - a rename or a role change lands in place without reordering the list, since membership changes don't bump server-side activity. Two cases do more than merge:
- You were added to a group: a
participant.addednaming the viewer arrives for a conversation the list has never seen; the client fetches it once and prepends it (the fetch carries your realunreadCount, which the event snapshot doesn't). - You were removed (or left): your own
participant.removedis the last event you receive for that conversation, and the client drops it from every cache surface - any later request for it would beFORBIDDEN_READ.
Like reactions, these events carry no id: line and are not gap-filled after
a reconnect: a membership change allocates no seq, so replaying it would
rewind Last-Event-ID. Refetch conversations when the stream reopens.
The conversations list stays live too
A message.created event for any conversation updates the cached list, not just
the open thread:
- the conversation moves to the front, matching the server's most-recently-active ordering;
- its
unreadCountincrements, unless the sender is the viewer; - a conversation the list has not seen yet is fetched once and prepended, so a brand-new thread started by the other user appears immediately.
message.updated and message.deleted never reorder the list, because editing
or deleting a message does not change server-side activity ordering. Redelivered
events (at-least-once delivery, Last-Event-ID gap-fill) never double-count:
only a strictly higher seq than anything already cached bumps a count.
conversations.markRead clears unreadCount locally when the marked message is
the newest one the client knows about, mirroring the server's monotonic
read-state.
Reaction events
reaction.added and reaction.removed replace one cached message's
reactions and touch nothing else: no reorder, no unread bump, no change to the
seq baseline that decides whether a later message counts as new. The event
carries the message's complete reaction set, so applying it twice is harmless,
and the cache merges only that field — a stale body or replyTo in the
payload can never clobber what it already holds. A reaction on a message
outside the loaded page is dropped rather than spliced into a paginated list.
Reactions are not gap-filled on reconnect: they have no seq, so the
Last-Event-ID protocol can't replay them (see
Real-time with SSE).
A reaction applied while the client was disconnected appears on the next
refetch of that thread.
chatClient.realtime.subscribe((event) => {
if (event.type === "reaction.added") console.log(event.actorId, event.emoji);
});isReactionChatEvent(event) is exported for narrowing — each ChatpackEvent
member has a union of literal type values, which TypeScript can't use to
eliminate a member, so an inline event.type === "reaction.added" check does
not narrow the way you'd expect.
Pass userId when creating the client so the viewer's own messages are never
counted as unread. It is a cache hint, never authentication — the server's auth
hook remains the only source of identity. Without it, the client infers the id
from the first message it sends.
const chatClient = createChatClient({ userId: currentUser.id });You do not need to refetch the conversation list on every event. If you wrote
that workaround against client 0.1.x, remove it.
Ephemeral events such as typing and presence are delivered to subscribers and
plugins, but are never inserted into message history. Stream status is
idle, connecting, open, closed, or polling, with a typed network error
when a connection fails.
Polling fallback
Some platforms cannot hold a long-lived connection: serverless functions time
out mid-response, corporate proxies buffer text/event-stream, and React Native
has no EventSource at all. Before 0.4.0 the client reported closed and simply
stopped updating. Now it refetches on an interval instead.
const chatClient = createChatClient({
realtime: {
mode: "auto", // "auto" (default) | "sse" | "poll"
intervalMs: 5000, // default 5000, clamped to a 1000ms floor
},
});| Mode | Behaviour |
|---|---|
auto (default) | Open the stream; poll only if it can't open or drops, and stop polling the moment it reopens. A serverless deploy works unconfigured. |
sse | Stream only, never poll. The pre-0.4.0 behaviour — for hosts that would rather surface the failure than pay for polls. |
poll | Never attempt a stream. Use when you know the platform can't hold one; skips the failed attempt and the staleness it costs. |
What a tick refetches
The conversations list and the 3 most recently used threads — either alone
leaves half the UI frozen. Only surfaces you have already loaded are polled, at
the same limit you last requested for them.
Polling re-reads page one of the existing list routes rather than asking for
messages after a seq. It has to: only sending a message allocates a seq, so
an edit, a delete and every reaction change would be invisible to an incremental
poll — you'd see new messages and silently miss every correction and tombstone.
Four things the loop does that a hand-rolled one usually doesn't:
- Ticks never overlap. A slow response skips the next beat instead of stacking requests on the connection least able to take them.
- A hidden tab doesn't poll, and catches up immediately when shown again.
- A flapping stream doesn't stack timers or buy an extra request per flap.
- A failed tick changes nothing and retries on the next. Polls never touch
isPendingorisRefetching, so components don't flash a spinner on a timer.
Polled pages merge rather than replace: unchanged data notifies no subscribers
(so an idle interval causes no re-renders), pagination you've loaded is never
truncated, and a polled message never double-counts against unreadCount. The
comparison covers a conversation's name and its participants' roles too, so
a group rename or a promotion made elsewhere re-renders on the next tick.
Typing, presence and receipts don't work while polling
They are ephemeral and never stored, so there is no endpoint to poll and nothing
to poll it for — useTyping() stays null. This isn't a gap to be closed later;
persisting them would reverse
ADR 0008 for the platforms least able to afford the
writes. If you need typing indicators, you need a runtime that holds a
connection. Design the UI so their absence degrades quietly.
Reporting it to the user
const { status, error } = chatClient.useRealtimeStatus();
// "polling" is connected-but-degraded, not an error.
if (status === "polling") return <span>Live updates every few seconds</span>;
if (status === "closed" && error !== null) return <span>Reconnecting…</span>;realtime.pollNow() runs one refresh immediately, for a manual "check for new
messages" action.
There is no WebSocket transport, token-in-query behavior, or persistent browser storage in the client.