Appearance
Realtime (SSE)
The @apco/realtime SDK adds live updates to your app with Server-Sent Events — a server-side broker that runs inside your SSR app (no platform service, no external dependency), plus a browser client with automatic reconnection and an optional React hook.
A full working app using it — the realtime-todo collaborative demo — is covered in the walkthrough below.
Server
Create one realtime instance per app, publish events from your routes, and mount an SSE endpoint:
ts
import { createRealtime } from "@apco/realtime";
const rt = createRealtime({ channels: ["todos"] });
// Publish from any route after a write:
const result = rt.publish("todos", { event: "todo:created", data: row });
if (!result.delivered) console.warn("realtime drop", result.reason);
// Mount the SSE endpoint (Fetch-style, e.g. Hono):
app.get("/api/events", (c) => rt.handler(c.req.raw));createRealtime(options) returns { publish, handler, node, clientCount }:
| Member | Signature | Notes |
|---|---|---|
publish | publish(channel, { event?, data }) | event maps to the SSE event: type (default "message") |
handler | handler(request: Request): Response | Fetch-API adapter (Hono, Next.js route handlers, Bun, ...) |
node | node(req, res): void | Node http adapter (Express, plain http.createServer) |
clientCount | clientCount(): number | Currently connected clients |
Options:
channels?: string[]— allowlist of channel names; omit to allow any. Publishing to a channel not in the list returns{ delivered: false, reason: "channel_not_allowed" }.maxClients?: number— max concurrent SSE clients, default 25. Extra clients get a 503. Set it from your plan'smax_sse_connectionslimit (see Plans & quotas); values are clamped to a hard ceiling of 1000.heartbeatMs?: number— keep-alive comment interval, default 30 000 ms.onDrop?: (drop) => void— central callback for dropped publishes.throttle?: false | { maxTokens?, refillIntervalMs?, refillTokens? }— customize or disable the per-channel publish throttle.replay?: { bufferSize? } | false— SSE resume buffer, default{ bufferSize: 100 }. Replays events a reconnecting client missed (see Resuming after a reconnect). Passfalseto disable it.authorize?: (channel, request) => boolean | Promise<boolean>— per-channel subscription gate (see Authorizing subscriptions).
Publishes are throttled
Each channel has a token bucket of roughly 10 events per second by default. Bursts beyond that return { delivered: false, reason: "throttled" }. Coalesce rapid updates instead of publishing one event per row.
Client (browser)
ts
import { subscribe } from "@apco/realtime/client";
const sub = subscribe({
url: "/api/events", // relative URLs work in the browser
channels: ["todos"],
events: ["todo:created", "todo:updated", "todo:deleted"],
parseJson: true,
onOpen: () => console.log("live"),
onEvent: (evt) => {
console.log(evt.channel, evt.event, evt.json ?? evt.data);
},
});
// later:
sub.close();subscribe(options) returns { close(), status() } and reconnects automatically with exponential backoff (starting at reconnectMs, default 1 s, capped at maxReconnectMs, default 30 s). onOpen, onStatus, onReconnect, and onError callbacks let you drive UI state and logging.
For bundled apps, avoid repeating event-name arrays by defining them once:
ts
import { defineRealtimeEvents, eventsFor } from "@apco/realtime/events";
const realtimeEvents = defineRealtimeEvents({
todos: ["todo:created", "todo:updated", "todo:deleted"],
});
subscribe({
url: "/api/events",
channels: ["todos"],
events: eventsFor(realtimeEvents, "todos"),
onEvent: () => refetchTodos(),
});Named events must be listed
An event published with a custom event: type (like todo:created) is a named SSE event — EventSource only delivers those to explicit listeners, never to the default message handler. List every named event type you use in events, or you'll silently receive nothing. Default (unnamed) events are always delivered.
Relative vs absolute URLs
Relative URLs (/api/events) resolve against the page in a browser. In Node (tests, server-side subscribers) there's no location, so pass an absolute URL.
React hook (optional)
tsx
import { useRealtime } from "@apco/realtime/react";
function Todos() {
const { connected, status } = useRealtime({
url: "/api/events",
channel: "todos",
events: ["todo:created", "todo:updated", "todo:deleted"],
onEvent: () => refetchTodos(),
});
return <Badge>{connected ? "live" : "connecting…"}</Badge>;
}The hook subscribes on mount, cleans up on unmount, reconnects automatically, and reports both connected and the lower-level status. An enabled option (default true) lets you toggle the subscription.
Resuming after a reconnect
Every delivered event is stamped with an SSE id, and each realtime instance keeps a bounded ring buffer of recent events (replay, default the last 100 events). When a client reconnects, the browser client sends the last id it saw and the server replays the events it missed before live events resume — so a brief network blip no longer drops updates. This is automatic; you don't wire anything up.
It is best-effort, though, and only covers same-instance reconnects within the buffer window. These cases still fall through to no replay:
- Cross-instance reconnects — the client comes back on a different replica (or after a restart), whose buffer doesn't recognise the id.
- Buffer overruns — more than
bufferSizeevents were published while the client was away. - The startup gap — with the Postgres adapter, events published by peers before this instance's
LISTENis established.
So keep a refetch on connect as your safety net — it makes all three cases invisible:
ts
subscribe({
url: "/api/events",
channels: ["todos"],
events: ["todos_changed"],
onOpen: () => refetchTodos(), // resync whatever replay didn't cover
onEvent: () => refetchTodos(),
});Pass replay: false to createRealtime to opt out of the buffer entirely (this also removes the id: line, restoring the exact legacy wire format).
Authorizing subscriptions
By default any client can subscribe to any channel your server allows. Pass an authorize(channel, request) hook to gate subscriptions — e.g. check a session cookie:
ts
const rt = createRealtime({
channels: ["todos"],
authorize: (channel, request) => {
// Fetch Request in handler(); IncomingMessage in node().
const cookie = request.headers.get?.("cookie") ?? "";
return userCanSee(channel, cookie);
},
});The hook runs at subscribe time for every requested channel:
- Channels it rejects are silently filtered out of the subscription; if the request named channels and none survive, it's rejected with 403.
- A subscribe-all request (no
channels) is rejected with 400 — wildcards can't be authorized per channel, so clients must name their channels. - A throwing hook fails closed (403).
Setting authorize makes handler() asynchronous (it awaits the checks) — just return the promise; Fetch-API servers like Hono accept it. See the reference for the full rules.
Walkthrough: realtime-todo
The realtime-todo app is a collaborative todo list on Hono, combining @apco/db and @apco/realtime:
POST/PATCH/DELETE /api/todoswrite to Postgres, then publishtodo:created/todo:updated/todo:deletedon thetodoschannel.GET /api/eventsserves the SSE stream viart.handler(c.req.raw).- The frontend subscribes to those three named events and refetches the list on any of them. Two tabs stay in sync without reloading. The example keeps raw
EventSourcein the browser because it deliberately has no frontend build step; bundled apps should usesubscribe().
Scaffold a new project with the same manifest (SSR + database):
bash
apco init --template realtime-todoScaling beyond one instance
The hub above is in-process — a client only sees events published by the instance it connected to. When more than one copy of your app runs (production plus apco dev --sync, or multiple replicas) and they must share events, pass a cross-instance adapter to createRealtime. The built-in Postgres LISTEN/NOTIFY adapter rides your existing @apco/db connection — no broker required.
See Scaling realtime beyond one instance for the adapter model, Postgres setup, the ~8 KB NOTIFY payload limit, and the coalesce/refetch pattern.
Related
@apco/realtimereference — full API- Scaling realtime — cross-instance adapter
- Managed Postgres — the data layer underneath
- Plans & quotas — SSE connection sizing