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.