Appearance
Scaling realtime beyond one instance
By default @apco/realtime is in-process: the SSE hub lives inside your app's process, so a client only receives events published by the same instance it connected to. That's exactly right for a single app instance — zero infrastructure, zero external dependency.
You need cross-instance realtime when more than one copy of your app is running and they must share events, for example:
- production plus a live
apco dev --syncsession, or - multiple replicas of the same app behind the router.
Without a shared transport, an event published on instance A never reaches a client connected to instance B.
The adapter model
createRealtime takes an optional adapter that bridges instances. Each instance still runs its own in-process hub; the adapter forwards locally published events to the others and feeds their events back into the local subscribers:
- A local
publish()that passes the channel allowlist and throttle is delivered locally and forwarded to the adapter. - Events arriving from the adapter go straight to local subscribers. They skip the throttle (already throttled at their origin) and are never re-forwarded (no loops); the channel allowlist still applies.
- Adapter failures surface through
onAdapterErrorand are never thrown back into yourpublish()call.
In-process stays the default — omit adapter and nothing changes.
Postgres LISTEN/NOTIFY
If your app already has a managed Postgres database, you already have a shared transport. createPostgresAdapter uses Postgres LISTEN/NOTIFY over your existing @apco/db connection — no broker, no Redis, nothing extra to provision.
ts
import { createDb } from "@apco/db";
import { createRealtime } from "@apco/realtime";
import { createPostgresAdapter } from "@apco/realtime/postgres";
const db = createDb();
const rt = createRealtime({
channels: ["todos"],
adapter: createPostgresAdapter({
sql: db.sql,
onError: (err) => console.warn("realtime adapter", err.code),
}),
});Pass the app's existing db.sql — a postgres.js Sql instance. The adapter's listen() gets its own dedicated connection courtesy of postgres.js, so it won't starve your query pool. All instances publish to and listen on a single NOTIFY channel (default apco_realtime); each instance drops its own echoes.
Call rt.close() on shutdown to unlisten and release the adapter.
No runtime dependency on postgres
@apco/realtime never imports postgres — the sql parameter is typed structurally, so the postgres adapter adds nothing to your bundle beyond what @apco/db already pulls in.
The ~8 KB payload limit
Postgres caps a NOTIFY payload at ~8000 bytes. The adapter serializes each event as a JSON envelope { origin, channel, event, data } and enforces a conservative 7500-byte budget: anything larger is not sent, the adapter reports payload_too_large through onError, and the event stays local-only (so that instance's own clients still see it, but peers don't).
This is a hard ceiling, not a suggestion — you cannot ship large rows through NOTIFY. Design for it.
Coalesce and refetch
The payload limit points at the pattern you want anyway: don't ship state over realtime — ship a hint and let clients refetch over HTTP.
Instead of broadcasting a full row on every write:
ts
// ❌ ships the whole row through NOTIFY — hits the 8 KB wall on big rows
rt.publish("todos", { event: "todo:updated", data: bigRow });publish a compact invalidation event and have clients pull the current state:
ts
// ✅ tiny, always fits; clients refetch GET /api/todos on any of these
rt.publish("todos", { event: "todos_changed", data: { type: "todos_changed" } });ts
subscribe({
url: "/api/events",
channels: ["todos"],
events: ["todos_changed"],
onEvent: () => refetchTodos(), // pull the authoritative state over HTTP
});This keeps payloads tiny and has a second benefit: it makes clients resilient to the gaps realtime can't close on its own. Same-instance reconnects within the replay buffer window are replayed automatically — a brief blip loses nothing. But three cases still fall through to no replay, and cross-instance scaling makes the first two routine:
- Cross-instance reconnects — a client that reconnects to a different replica hits a buffer that doesn't recognise its resume id, so nothing is replayed.
- Buffer overruns — under a burst, more than
bufferSizeevents (default 100) are published while a client is away, or the per-channel throttle drops events at the source. - The startup gap (below) — events published by peers before this instance's
LISTENis live.
A "refetch on any change" client that also refetches on reconnect covers all three — it resyncs the authoritative state the moment it reconnects, regardless of which instance it lands on:
ts
subscribe({
url: "/api/events",
channels: ["todos"],
events: ["todos_changed"],
onOpen: () => refetchTodos(), // resync whatever replay didn't cover (any instance)
onEvent: () => refetchTodos(),
});Startup gap
There is a brief window between createRealtime() returning and the adapter's underlying LISTEN actually being established. Events published by peers during that window are missed on this instance — and, being adapter-inbound, are outside what per-instance replay can recover. A client that refetches on onOpen (above) resyncs anyway, so this is invisible in the coalesce-and-refetch pattern; it only bites apps that treat the realtime stream as the source of truth.
Related
@apco/realtimereference — the adapter API- Realtime (SSE) — the single-instance basics
- Managed Postgres — the shared transport underneath
- Hybrid dev —
apco dev --syncalongside production