Realtime
live() is find() that keeps arriving. The first snapshot lands at once, then a fresh one lands whenever a matching row changes.
A list is on screen. Someone else edits a row. This page is how that list catches up, without a refresh and without a poll.
Subscribe to a query
live() takes the query you would have passed to find(), plus a callback. It
returns a function that stops the subscription.
import { client } from "@/lib/client";
const unsubscribe = client.collections.posts.live(
{ where: { published: true }, orderBy: { createdAt: "desc" }, limit: 10 },
(snapshot) => {
render(snapshot.docs);
},
);
// Later, on unmount:
unsubscribe();The callback fires once straight away with the current result. It fires again
after every change that matches. So you do not need a find() before you
subscribe. Treat the first call as "data loaded".
A snapshot is a find() result, same shape and same type. You get docs,
totalDocs, hasNextPage and any relations you asked for in with. QUESTPIE
re-runs the query under the subscriber's own session before each push. A
subscription cannot show a row that reader is not allowed to read.
Every live() call on one client shares a single stream. Two calls with
identical options share one topic on it, and both callbacks get every snapshot.
Change the where and you get a second topic.
You do not turn it on
Realtime ships in the core module. Change capture, the POST /realtime
endpoint and the SSE transport are already there on a plain db: { url } app.
realtime: true resolves to {}, which is what an empty config gives you.
Write the object form only to change a default.
Two switches turn it off. realtime: { rowLiveQueries: false } closes every
collection and global topic app-wide. .options({ realtime: false }) closes one
collection. See Realtime adapter.
What live() accepts
| Option | What it does |
|---|---|
where | The filter. Same operators as find() |
with | Relations to hydrate into every snapshot |
limit | Rows per snapshot. The default cap is 100 |
offset | Rows to skip |
orderBy | The sort. Same shape as find() |
locale | Which localized variant to read |
That is the whole list. columns, groupBy, search, includeDeleted and
stage are find() options that live() does not take. The client builds the
topic by copying the fields above and dropping the rest, so nothing else reaches
the server. Use a one-shot find() when you need one of them.
Stopping a subscription
Always stop one. A leaked subscription holds a topic open and keeps the server pushing snapshots at nobody.
const controller = new AbortController();
client.collections.posts.live(
{ where: { published: true } },
(snapshot) => render(snapshot.docs),
{
signal: controller.signal,
onError: (error) => showStaleBanner(error),
},
);
controller.abort(); // same as calling the returned functionThe third argument is LiveSubscribeOptions. signal unsubscribes on abort, so
you can hand it a component's lifetime. onError fires when the server refuses
this topic, and when the connection fails in a way the client will not retry.
Wire it. Without it a dead subscription looks exactly like one with no news.
A refused topic reaches only that topic's onError, as a
RealtimeTopicRejectedError. Sibling subscriptions stay up.
Errors and limits has the reason codes.
A dropped connection heals itself
The client reconnects with backoff, then resends each topic with the sequence
number it last saw. You do not write that loop, and you do not hear about it.
A retryable drop never reaches onError.
liveIter(), the loop form
liveIter() yields the same snapshots as an async generator. Use it in a
worker, a script or a test, where for await reads better than a callback.
const controller = new AbortController();
for await (const snapshot of client.collections.posts.liveIter(
{ where: { published: true }, limit: 10 },
{ signal: controller.signal },
)) {
console.log("posts now:", snapshot.totalDocs);
}It takes { signal } and nothing else. There is no onError here. A refused
topic or a dead connection makes the generator throw, so wrap the loop in
try/catch. Abort the signal to end it.
Globals
A global is one row, so it takes with and locale only.
const stop = client.globals.settings.live({ with: { logo: true } }, (s) => {
document.title = s.siteName;
});The snapshot equals the get() result. Both forms behave as they do on a
collection.
With TanStack Query
Pass { realtime: true } as the second argument to a query builder. The hook
streams instead of fetching once.
import { useQuery } from "@tanstack/react-query";
import { q } from "@/lib/query";
function LivePosts() {
const { data } = useQuery(
q.collections.posts.find({ limit: 10 }, { realtime: true }),
);
return <p>{data?.totalDocs ?? 0} published posts</p>;
}Three reads accept the flag: collections.<name>.find,
collections.<name>.count and globals.<name>.get. findOne rejects it at
compile time, and there is no live findOne. Subscribe with
find({ where: { id } }) and read docs[0].
The query key is identical to the non-realtime version, so both share one cache entry. The stream supplies the first value, so the flag adds no extra REST call. TanStack Query covers the rest of the builder.
A live count sends one number
q.collections.posts.count(options, { realtime: true }) opens a count
topic. The server counts and streams the scalar. It never serializes the
matching rows.
Locale belongs to the topic
locale is part of the subscription. So en and de are two separate streams
of the same query.
client.collections.posts.live({ locale: "de" }, render);client.setLocale() sets the accept-language header on later requests. It
does not retarget an open subscription. Unsubscribe and subscribe again with the
new locale.
Where each topic lives
| Topic | Page |
|---|---|
| Refused topics, limits, reason codes | Errors and limits |
Hand-built topics and client.realtime | Raw subscriptions |
| Keyed row events instead of full snapshots | Row deltas |
| Brokers, admission config, the outbox | Realtime adapter |
| Picking a primitive, bounding React renders | Reactive apps |
| Two people in one field | Collaborative documents |
Next
Channels is the other half. A live query carries your data. A channel carries an event you published on purpose.