Errors and limits
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.
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.
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 instead.
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.
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.
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.
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.
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.
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 is the server side of every number on this page, and how to raise one after you have measured it.
Row deltas
A snapshot resends the whole page on every change. A delta sends the one row that moved. Most apps never need to ask for it, and this page says why.
Raw subscriptions
client.realtime is the untyped layer under live(). Build a topic yourself when the collection name is a variable, or when you want the keyed events instead of whole snapshots.