QUESTPIE
ClientRealtime

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.

View markdown

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.

FieldValue
code"REALTIME_TOPIC_REJECTED"
retryablefalse, always
topicIdThe id of the topic that was refused
resourceThe collection or global name
operation"find", "count" or "get"
detailsreason, 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.

ReasonWhat you didFix
query_limitlimit above the cap, 100 by defaultAsk for fewer rows, or raise the cap
relation_depthwith nested deeper than 3Flatten the relation tree
snapshot_bytesA delta bootstrap serialized too largeNarrow the where, or use snapshots
row_live_queries_disabledThe app set realtime: { rowLiveQueries: false }Read once, or use a channel
collection_realtime_disabledThe 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 silentTerminal, and one onError
The socket or the stream droppingAny other 4xx that names no topic
Nothing on the stream for 25 secondsA server too old for the topology protocol
408, 425, 429 or any 5xxA 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.

On this page