Multi-node fan-out
Run more than one app server and keep /stream working, with the Redis pub/sub transport.
Chatpack's default transport fans events out inside one process. That is correct for a single server and silently wrong for two:
┌─ node A ── Alice's /stream connection
load balancer ──────┤
└─ node B ── Bob's POST /messagesBob's message is stored (both nodes share one database), and node B publishes it to node B's listeners. Alice is on node A. She sees nothing until she reconnects.
@chatpack/transport-redis relays every published event through a Redis channel,
so all nodes see all events.
Install
npm install @chatpack/transport-redis ioredisioredis (or node-redis) is yours to choose - Chatpack has no Redis
dependency of its own.
Use it
import { chatpack } from "@chatpack/core";
import { drizzleAdapter } from "@chatpack/adapter-drizzle";
import { redisPresenceStore, redisTransport } from "@chatpack/transport-redis";
import { presence } from "@chatpack/core/plugins";
import { Redis } from "ioredis";
const publisher = new Redis(process.env.REDIS_URL!);
export const chat = chatpack({
storage: drizzleAdapter(db),
auth: async (req) => getSessionUser(req),
transport: redisTransport({
publisher,
subscriber: new Redis(process.env.REDIS_URL!),
}),
plugins: [presence({ store: redisPresenceStore({ client: publisher }) })],
});That's the whole change. Transport is the seam core was built around, so every
route, event, and client stays identical.
Two separate connections are required. A Redis connection that has issued SUBSCRIBE enters
subscriber mode and refuses PUBLISH. Passing the same client as both publisher and
subscriber throws at startup rather than failing on the first message.
Import the named Redis, as above - not the default export. They are the same class at
runtime, but ioredis is CommonJS, so in an ESM package on "module": "nodenext" TypeScript types
the default export as the module object and new Redis(url) fails to compile. The named import
works in every module setting.
With node-redis
import { createClient } from "redis";
const publisher = createClient({ url: process.env.REDIS_URL });
const subscriber = publisher.duplicate();
await Promise.all([publisher.connect(), subscriber.connect()]);
const transport = redisTransport({ publisher, subscriber });Chatpack has no Redis dependency - redisTransport accepts anything with the
right shape, so there is no driver version to keep compatible.
Options
| Option | Default | Notes |
|---|---|---|
publisher | (required) | Client used to PUBLISH. |
subscriber | (required) | A second client, used to SUBSCRIBE. |
channel | chatpack:events | Override to isolate staging from production on a shared Redis. |
nodeId | random | This process's id, used to drop its own echoed events. Override in tests. |
onError | console.error | (error, "publish" | "receive" | "subscribe"). Wire to your tracker. |
The return value is a standard Transport plus nodeId and close().
close() unsubscribes and drops local listeners; closing the Redis connections
is yours to do, since this package didn't open them.
What goes multi-node - and what doesn't
| Feature | Multi-node? |
|---|---|
message.created / message.updated / .deleted | ✅ Yes |
reaction.added / reaction.removed | ✅ Yes |
participant.added / .removed / conversation.updated | ✅ Yes |
typing() indicators | ✅ Yes |
receipts() delivered/read ticks | ✅ Yes |
presence() online/offline with shared store | ✅ Yes |
Configure both Redis pieces. redisTransport() relays events. redisPresenceStore() shares
expiring per-connection leases and must use a normal publisher/general Redis connection; the
subscriber connection remains dedicated to SUBSCRIBE.
When Redis goes down
A Redis outage degrades live delivery; it never fails a send.
publish() is synchronous and never throws - the Transport contract requires
it, because the message is already in storage before anyone is notified
(durable-first). Local subscribers are notified inline, before Redis is
involved at all.
During an outage:
- senders still succeed (their write is durable),
- history stays correct,
- clients on the publishing node stay live,
- clients on other nodes miss events until they reconnect - at which point
Last-Event-IDgap-fill replays the gap from storage.
Failures surface through onError instead of the request path.
Redis pub/sub itself is at-most-once - there's no replay buffer, which is exactly
why durable events remain replayable from storage and ephemeral ones are defined
as droppable. Reaction and membership events sit in between: they're stored, but
they have no seq, so Last-Event-ID can't replay them either - one missed
during an outage appears on the next refetch of that conversation
(Reaction events).
Notes
- No sticky sessions needed. Any node can serve any stream.
- Ordering is per-publisher: two nodes publishing concurrently can
interleave on the wire. Clients sort by
seq, so message order is unaffected. - Serverless is still not a fit for SSE, whatever the transport - the function lifetime is the blocker, not the fan-out. Poll there instead.
- One database, many app servers. All nodes must share storage; the transport only relays live events, it isn't a substitute for shared persistence.
Verify it
Start two servers on different ports against the same Postgres and Redis, open a stream on the first, and send through the second:
# terminal 1 - stream from node A
curl -sN localhost:3000/api/chat/stream -H 'cookie: demo_user=alice'
# terminal 2 - send through node B
curl -si -X POST localhost:3001/api/chat/conversations/$CONV/messages \
-H 'cookie: demo_user=bob' -H 'content-type: application/json' \
-d '{"body":"across nodes"}'The message.created event should appear on node A's stream. Without the Redis
transport, it won't.
Design rationale: ADR 0012.