Realtime
Turn a read into a live subscription. QUESTPIE captures every write in the same transaction, wakes the other instances through a broker, and re-runs each query under its own subscriber's access rules before pushing the snapshot.
Two seams sit under that, and you can swap either one. A change broker
carries wake-up notices between your app instances. A client transport
carries authorized frames out to browsers. Application code calls live() and
client.channels.* and never learns which pair you picked.
What you get with no configuration
Nothing. Write no realtime block and realtime still runs, because change
capture, the POST /realtime SSE endpoint and the channel routes ship in the
core module. On a db: { url } app you get:
| Piece | Zero-config default |
|---|---|
| Capture | One row in questpie_realtime_log per write, in the mutation's transaction |
| Broker | PgNotifyChangeBroker on channel questpie_realtime_v2, auto-wired |
| Transport | Multiplexed SSE on POST /realtime, keep-alive ping every 8000 ms |
| Reconcile | A 15000 ms poll of the same outbox, so a dropped notice heals |
| Retention | Outbox rows deleted after 3 days |
realtime: true is accepted and resolves to {}, which is exactly what
omitting the key already gives you. Reach for the object form when you want to
change a default.
The wake is a cue, not the data
A broker message carries bounded routing metadata, identifiers and counters. Rows live in the outbox, and every snapshot is recomputed under the subscriber's own session before it leaves the instance. No broker ever sees another user's data.
The two primitives
Live queries carry a fresh snapshot of a collection or global, so use them for lists, detail views and counters. Channels carry a typed application event, so use them for progress, typing and notifications.
const unsubscribe = client.collections.posts.live(
{ where: { status: "published" }, orderBy: { createdAt: "desc" } },
(snapshot) => render(snapshot.docs),
);The callback fires once with the current snapshot, then again on each relevant
change. liveIter() yields the same snapshots as an async generator.
Client realtime has the lifecycle options and the
TanStack Query integration. Channels covers
channel(), authorization, presence and replay.
The adapters
Each seam has its own choices and you may mix them. Redis for the notice with SSE at the edge is a normal combination.
| Adapter | Seam | Import | Needs | Pick it when |
|---|---|---|---|---|
| Polling | broker | none, this is the fallback | nothing | No Postgres URL is reachable. Falls to a 2000 ms poll |
pgNotifyChangeBroker | broker | questpie/adapters/pg-notify | pg, an optional peer at ^8.13.1 | The default. One database, no extra infrastructure |
redisStreamsChangeBroker | broker | questpie/adapters/redis-streams | A Redis client you construct yourself | Many instances, or Redis is already in the stack |
| SSE | transport | none, this is the default | nothing | Almost always. Presence is Postgres-backed |
pusherRealtime() | both | questpie/adapters/pusher | The pusher and pusher-js peers, and a Pusher or Soketi server | You want managed WebSockets and provider presence |
pusherRealtime() returns { changeBroker, clientTransport }, so it fills
both seams at once. The isolated entry point is the only thing that loads those
two optional peers.
Swapping the broker
Build the client, hand it over, change nothing else:
import { runtimeConfig } from "questpie/app";
import { redisStreamsChangeBroker } from "questpie/adapters/redis-streams";
import { createClient } from "redis";
import env from "./env";
const redis = createClient({ url: env.REDIS_URL });
await redis.connect();
export default runtimeConfig({
db: { url: env.DATABASE_URL },
realtime: {
changeBroker: redisStreamsChangeBroker({
client: redis,
// stream: "questpie:realtime:v2", // default
// blockMs: 5000, batchSize: 100, maxLen: 10_000,
}),
},
});Every instance tails the stream from $ with its own XREAD cursor, so each
one receives every wake rather than competing for it. XADD applies
MAXLEN ~ 10000. Only xAdd and xRead are mandatory on the client, and a
node-redis client is duplicated automatically for the blocking read. Pass
reader when your client cannot duplicate itself.
Moving to managed WebSockets is the same edit against the other seam:
import { pusherRealtime } from "questpie/adapters/pusher";
export default runtimeConfig({
db: { url: env.DATABASE_URL },
realtime: pusherRealtime({
appId: env.PUSHER_APP_ID,
key: env.PUSHER_KEY,
secret: env.PUSHER_SECRET,
cluster: env.PUSHER_CLUSTER,
// Soketi: host, port, wsHost, wsPort, wssPort, useTLS
}),
});live() and client.channels.* are untouched by either edit. The client
learns which transport it is on from GET /realtime/config.
Split tiers need a broker every writer can reach
The outbox keeps a polling-only worker correct, but it adds latency and database load. Point the API, the workers and the subscriber-serving instances at the same explicit broker rather than assuming each process inherited a database URL.
Configuring the pg broker by hand
You rarely need to. Configure it explicitly for a non-default channel, to reuse
an existing pg client, or to aim realtime at a second database.
import { pgNotifyChangeBroker } from "questpie/adapters/pg-notify";
realtime: {
changeBroker: pgNotifyChangeBroker({ connectionString: env.REALTIME_DB_URL }),
}Give it one connection source. With none of client, connection or
connectionString it throws on start, and if you pass several it does not
complain, it just prefers client, then connection. A client you hand over is
left open on stop(), one built from a connection string is closed. Channel
names are checked against /^[a-zA-Z0-9_]+$/ at construction.
Turning it down
rowLiveQueries: false is the app-wide switch for collection and global row
topics, and leaves channels, CRDT and outbox capture running. Per collection,
.options({ realtime: false }) opts one table out. Both refuse the topic at
admission with REALTIME_TOPIC_REJECTED rather than degrading quietly.
nativeDeltas is false by default. Until you turn it on, a topic that
qualifies for keyed deltas is served as a snapshot instead, so correctness never
waits on a rollout. Scalable realtime is the
page for high-fanout modeling.
Writing your own broker
Implement three methods and wire the result as realtime.changeBroker.
interface ChangeBroker {
start(input: {
onWake: (wake: ChangeWake) => void;
onError: (error: unknown) => void;
onStateChange?: (state: ChangeBrokerState) => void;
}): Promise<void>;
publish(wake: ChangeWake): Promise<void>;
stop(): Promise<void>;
}Wakes may be lost, duplicated, delayed, reordered or coalesced, and they must
never carry rows, snapshots or credentials. Reconciliation against the durable
outbox is what supplies correctness, which is why a lossy broker is a safe
thing to write. ChangeBroker, ClientTransport, RealtimeConfig and the
outbox event types are exported from questpie/realtime.
Where each topic lives
| Topic | Page |
|---|---|
| Every option, admission limit and outbox column | Tuning |
| Pusher auth, revocation, client events | Pusher and Soketi |
live(), liveIter(), TanStack Query | Client realtime |
channel(), presence, replay | Channels |
| Scope, routing, high fanout | Scalable realtime |
| Subscription sizing and React performance | Reactive apps |
Where realtime sits in the config | Configuration |