Chatpack
Client

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.added naming the viewer arrives for a conversation the list has never seen; the client fetches it once and prepends it (the fetch carries your real unreadCount, which the event snapshot doesn't).
  • You were removed (or left): your own participant.removed is the last event you receive for that conversation, and the client drops it from every cache surface - any later request for it would be FORBIDDEN_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 unreadCount increments, 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

Client 0.4.0+. On by default — you should not hand-roll an interval.

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
  },
});
ModeBehaviour
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.
sseStream only, never poll. The pre-0.4.0 behaviour — for hosts that would rather surface the failure than pay for polls.
pollNever 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 isPending or isRefetching, 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.

On this page