# Realtime (/docs/client/realtime)

---
title: Realtime
description: live() is find() that keeps arriving. The first snapshot lands at once, then a fresh one lands whenever a matching row changes.
kind: guide
package: questpie
---

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.

```ts title="src/app/live-posts.ts"
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](/docs/infrastructure/realtime).

## 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.

```ts
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 function
```

The 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](/docs/client/realtime/errors) has the reason codes.

<Callout type="info" title="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`.
</Callout>

## 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.

```ts
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.

```ts
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.

```tsx
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](/docs/client/tanstack-query) covers the rest of the builder.

<Callout type="info" title="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.
</Callout>

## Locale belongs to the topic

`locale` is part of the subscription. So `en` and `de` are two separate streams
of the same query.

```ts
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](/docs/client/realtime/errors)               |
| Hand-built topics and `client.realtime`     | [Raw subscriptions](/docs/client/realtime/raw-api)              |
| Keyed row events instead of full snapshots  | [Row deltas](/docs/client/realtime/deltas)                      |
| Brokers, admission config, the outbox       | [Realtime adapter](/docs/infrastructure/realtime)               |
| Picking a primitive, bounding React renders | [Reactive apps](/docs/client/reactive-apps)                     |
| Two people in one field                     | [Collaborative documents](/docs/client/collaborative-documents) |

## Next

**[Channels](/docs/client/channels)** is the other half. A live query carries
your data. A channel carries an event you published on purpose.
