# Errors and limits (/docs/client/realtime/errors)

---
title: Errors and limits
description: A refused topic and a dropped connection look the same from a UI that shows nothing. They are not the same, and only one of them is worth retrying.
kind: guide
package: questpie
---

Your `onError` receives both kinds of failure through the same argument. The
first thing it should do is work out which one arrived. The rest of this page is
the fields you test, the reasons the server sends, and the numbers behind them.

## Catch the refusal

`RealtimeTopicRejectedError` comes from `questpie/client`. Test for it first,
then treat anything else as transport trouble.

```ts
import { RealtimeTopicRejectedError } from "questpie/client";

client.collections.posts.live({ limit: 500 }, render, {
	onError: (error) => {
		if (error instanceof RealtimeTopicRejectedError) {
			console.error(error.details.reason, error.details.configuredLimit);
			showPermanentError();
			return;
		}
		showReconnectingBanner();
	},
});
```

The error carries these fields.

| Field       | Value                                                        |
| ----------- | ------------------------------------------------------------ |
| `code`      | `"REALTIME_TOPIC_REJECTED"`                                  |
| `retryable` | `false`, always                                              |
| `topicId`   | The id of the topic that was refused                         |
| `resource`  | The collection or global name                                |
| `operation` | `"find"`, `"count"` or `"get"`                               |
| `details`   | `reason`, plus `requestedLimit` and `configuredLimit` if set |

It never carries your `where`, the session, a token, or any row. What you see
here is safe to log.

## The five reasons

`details.reason` tells you which rule you hit.

| Reason                         | What you did                                       | Fix                                  |
| ------------------------------ | -------------------------------------------------- | ------------------------------------ |
| `query_limit`                  | `limit` above the cap, 100 by default              | Ask for fewer rows, or raise the cap |
| `relation_depth`               | `with` nested deeper than 3                        | Flatten the relation tree            |
| `snapshot_bytes`               | A delta bootstrap serialized too large             | Narrow the `where`, or use snapshots |
| `row_live_queries_disabled`    | The app set `realtime: { rowLiveQueries: false }`  | Read once, or use a channel          |
| `collection_realtime_disabled` | The collection set `.options({ realtime: false })` | Read once, or use a channel          |

The first three are yours to fix in the query. The last two are server policy,
so no query change gets past them. Reach for a normal `find()` or a typed
[channel](/docs/client/channels) instead.

<Callout type="warn" title="An oversized topic is refused, not trimmed">
	QUESTPIE does not clamp `limit` down to the cap. Clamping would change your
	ordering, your pagination and your row count without telling you. A large read
	model is a job for pagination, not for one live snapshot.
</Callout>

## A refusal ends that topic

The client drops the topic when it is refused. It does not retry it, and it does
not reconnect over it. Your other subscriptions keep running on the same
connection.

So handle it once, in `onError`, and change the query. Calling `live()` again
with the same options produces the same refusal.

## A dropped connection does not

Most transport failures never reach `onError` at all. The client reconnects with
backoff and resends every live topic with the sequence number it last saw. You
get a fresh snapshot when it comes back and nothing in between.

The client sorts a failure into one of two piles.

| Retried, and silent                        | Terminal, and one `onError`                |
| ------------------------------------------ | ------------------------------------------ |
| The socket or the stream dropping          | Any other 4xx that names no topic          |
| Nothing on the stream for 25 seconds       | A server too old for the topology protocol |
| 408, 425, 429 or any 5xx                   | A stream response with no body             |
| A control session the server no longer has |                                            |

Backoff doubles from one second and caps at 30 seconds. Each wait is jittered
between half and one and a half of that. A keep-alive ping resets the counter.

So a connection failure that does reach `onError` is not a hiccup. It is the
client saying it has stopped trying. That is the one worth putting on screen.

<Callout type="info" title="Not every failure is a rejection payload">
	Some server errors reach `onError` as a plain `Error` carrying the server's
	message. So test for `RealtimeTopicRejectedError` first, and treat everything
	else as a failure nobody is retrying for you.
</Callout>

## The generator forms throw

`liveIter()`, `client.realtime.stream()` and `streamEvents()` have no `onError`.
Both kinds of failure make the generator throw, so a `for await` loop needs a
`try`/`catch`.

```ts
const posts = client.collections.posts.liveIter({ limit: 10 });

try {
	for await (const snapshot of posts) {
		render(snapshot.docs);
	}
} catch (error) {
	if (error instanceof RealtimeTopicRejectedError) return;
	scheduleRetry();
}
```

There is one more failure only these forms can hit. They buffer events between
loop turns, and they throw once the buffer passes 512 events or 1 MiB. A slow
consumer on a busy topic is what triggers it. `live()` has no buffer, because it
calls your callback as each frame lands.

## Under TanStack Query

The `{ realtime: true }` builders read `retryable` off the error. A refused topic
fails the query at once, with no retry. A transport failure retries up to three
times, then fails the query.

Either way the query lands in `error` state, so a component reads `error` and
`isError` as it would for a one-shot read.

```tsx
const { data, error } = useQuery(
	q.collections.posts.find({ limit: 10 }, { realtime: true }),
);
```

Your `errorMap` from `createQuestpieQueryOptions` still runs. If it returns a new
object, QUESTPIE keeps `retryable: false` on it so the no-retry rule survives the
mapping.

## Next

**[Tuning](/docs/infrastructure/realtime/tuning)** is the server side of every
number on this page, and how to raise one after you have measured it.
