Chatpack
Real-time

Real-time with SSE

One EventSource - live messages, automatic reconnection, and missed-message backfill from storage.

GET /stream is a Server-Sent Events endpoint. Each connected user receives message.created / message.updated / message.deleted, reaction.added / reaction.removed, and participant.added / participant.removed / conversation.updated events for their conversations only - participation is re-checked server-side per event.

No WebSocket server, no Socket.IO, no reconnect code to write.

The optional @chatpack/client package owns this EventSource, reconnects through the browser's native Last-Event-ID behavior, and deduplicates durable messages in its per-client cache. Where a stream can't be held at all it falls back to interval refetch. It never puts bearer tokens in the stream URL.

Connect

const events = new EventSource("/api/chat/stream");

// TypeScript: custom event names fall outside EventSourceEventMap, so cast
// the listener parameter to MessageEvent to access `.data`.
events.addEventListener("message.created", (e) => {
  const { message } = JSON.parse((e as MessageEvent).data);
  // dedupe by message.id (delivery is at-least-once), then render
});

events.addEventListener("message.updated", (e) => {
  const { message } = JSON.parse((e as MessageEvent).data);
  // re-render the edited message
});

events.addEventListener("message.deleted", (e) => {
  const { message } = JSON.parse((e as MessageEvent).data);
  // render a tombstone - body is "" and deletedAt is set
});

events.addEventListener("reaction.added", (e) => {
  const { actorId, emoji, message } = JSON.parse((e as MessageEvent).data);
  // `message.reactions` is the COMPLETE set after the change - replace, don't merge
});
// reaction.removed carries the same shape

events.addEventListener("participant.removed", (e) => {
  const { actorId, affectedUserIds, conversation } = JSON.parse((e as MessageEvent).data);
  // If affectedUserIds includes YOU, this is your removal - drop the
  // conversation. It's the last event you'll get for it.
  // Otherwise: replace your cached copy with `conversation`.
});
// participant.added and conversation.updated carry the same shape - including
// when someone self-joins a public channel (they are their own `actorId`) and
// when an admin flips a group's visibility (a `conversation.updated`)

events.onerror = () => {
  if (events.readyState === EventSource.CLOSED) {
    // Fatal (e.g. 401): the browser will NOT retry. Re-auth, then recreate.
  }
  // Otherwise: dropped connection - EventSource retries automatically with
  // Last-Event-ID and the server replays what was missed.
};

Mentions and forwards need no new event

Neither feature adds an event type. A mention rides along inside the message snapshot every message.created / message.updated frame already carries, so a client reads message.mentions and highlights the thread - there is no mention.added, and nothing to subscribe to.

A forward is an ordinary send in the target conversation: one message.created, delivered to the target's participants only, with a seq and therefore an id: line, so it gap-fills like any other message. The source conversation emits nothing at all - the original was not touched, and telling its participants that someone quoted them elsewhere would leak the destination.

Reaction events are a third category

reaction.added / reaction.removed are durable-backed - they are not ephemeral: true - but their frames deliberately carry no id: line. Last-Event-ID means "the newest message seq I have seen", and a reaction on a three-day-old message produces no new seq, so an id: here would poison gap-fill: the next reconnect would replay from the wrong place.

The consequence is honest and bounded: reactions are not gap-filled. A reaction applied while a client was offline arrives on its next refetch rather than as a replay. Refetch the message pages you have cached when the stream reopens after having been open - @chatpack/client does this for you.

The alternatives were worse. Bumping a message's seq when it's reacted to would make gap-fill work for free, but seq would stop being a stable creation-order key and reacting would reorder the transcript. A second replay cursor just for reactions would be exact, but adds a parallel protocol to the SSE contract for a payload that's cosmetic if briefly stale.

If you write a custom Transport, note that !isEphemeralEvent(e) therefore no longer means "a message" - branch on isMessageEvent(e) wherever the message snapshot matters.

No lost messages

Events are published only after the storage write succeeds (durable-first), and every durable event id is conversationId:seq. On reconnect, EventSource sends Last-Event-ID automatically and the server replays whatever was missed from storage before resuming live delivery.

Delivery is at-least-once - dedupe by message.id in the client.

Auth for the stream

EventSource cannot send custom headers, so your auth hook must resolve the user from what the browser sends automatically - typically a session cookie (same-origin cookies are sent by default; pass { withCredentials: true } for cross-origin).

Bearer-token schemes work for the REST routes but not for /stream - write the hook to accept either credential. Full recipes (including iframe-proof cookies for embedded previews) in Authentication.

Deployment reality check

For a new app that needs live chat, Railway is the easiest default for the Chatpack API because its Node process stays running and holds SSE connections. Render, Fly, or a server/VM also work. A Next.js frontend can stay on Vercel while the Chatpack API runs elsewhere. On Vercel Functions, Lambda, or edge runtimes, use a database adapter and polling. Redis relays between long-lived servers; it cannot keep a serverless function alive. With 2+ live servers, configure @chatpack/transport-redis so events reach streams on every node. See Deployment.

Heartbeats

The handler sends an SSE comment as a heartbeat every 15 seconds by default - tune it via chat.handler({ heartbeatIntervalMs }). This keeps proxies and load balancers from closing idle connections.

Verify it works

# expect ": connected", then events as messages are sent
curl -sN localhost:3000/api/chat/stream -H 'cookie: demo_user=bob'

Send a message as alice in another terminal and watch the message.created event arrive on bob's stream.

On this page