QUESTPIE
ClientRealtime

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.

View markdown

Snapshot is the default and it is the safe one. The server re-runs your query and pushes the whole result. Correctness never depends on a client applying a patch in the right order.

Deltas save bandwidth on a big, busy list. That is the whole benefit, and they stay off until two separate switches are on.

Check what you already have

streamEvents() on a collection find topic already gives you keyed insert, update and delete frames. The client diffs consecutive snapshots and emits the difference, keyed by String(row.id).

So a store that applies row events works today, with no server change. What server-side deltas buy you is the wire cost, not the shape. Raw subscriptions covers those frames.

Turn to this page when snapshots themselves are the problem. A hundred rows resent every second is the case that justifies it.

Turn it on

Step one is the server. nativeDeltas is false by default.

src/questpie/server/questpie.config.ts
import { runtimeConfig } from "questpie/app";

import env from "./env";

export default runtimeConfig({
	db: { url: env.DATABASE_URL },
	realtime: { nativeDeltas: true },
});

Deploy that in a second pass, not the first. Writers append to the outbox without a lock, and readers order it by (txid, seq) under PostgreSQL's visibility watermark. A node still running an older reader orders by sequence alone, which can skip a row. Ship the code, wait for the fleet, then flip the flag.

Step two is the topic. Deltas are opt-in per subscription, and only through the raw API.

for await (const event of client.realtime.streamEvents<PostsPage>({
	resourceType: "collection",
	resource: "posts",
	operation: "find",
	mode: "delta",
	where: { published: true },
})) {
	apply(event);
}

live() and liveIter() have no mode. They stay on snapshots on purpose.

Asking early is safe

With nativeDeltas off, a mode: "delta" topic gets the ordinary snapshot stream. Nothing errors and nothing is lost. So you can write the consumer first and turn the server flag on later.

Which queries qualify

The shape rules are strict, and a topic that breaks any of them silently stays on snapshots.

RuleWhy
A collection, not a globalA global is a single row
operation: "find"count and get have no row set to key
No limit, offset or orderByA window makes membership depend on other rows
No withRelations are not tracked in delta mode
columns must keep idid is the delta key
No RAW in whereThe server cannot inspect raw SQL
No relation field in whereMembership would depend on another table

Pusher and Soketi are snapshot-only, whatever the topic asks for.

What arrives

The first frame is always a full snapshot. Keyed events follow, then an up-to-date frame closes each batch and carries the new totalDocs.

Reduce each event as it lands, but do not render until up-to-date arrives. One transaction can produce several row events, and the states in between are not results anyone should see. subscribe() and stream() already hold the value back for you. Do the same in a hand-written consumer.

A reset is a fresh snapshot with reset: true. Replace your state with it rather than merging. Resets happen when:

  • A minute passes. Every delta group re-bootstraps on a timer
  • Something the query depends on changes, rather than the collection itself
  • One transaction touches more rows than the delta budget allows
  • The server's queue for this group overflows

Access is re-checked on every bootstrap and on every row hydration. It runs the same row, field and afterRead path a normal read uses. A delta never skips a rule that a find() would apply.

The budget

Delta topics have their own caps, separate from the 100-row snapshot limit.

CapDefaultWhat it bounds
maxDeltaFindLimit384Rows a delta topic may track
maxBufferedDeltaEvents512Events queued for one group
maxBufferedDeltaBytes1 MiBBytes queued for one group

maxDeltaFindLimit is lowered further when the byte caps imply a smaller bootstrap. Go past the row cap and the topic is refused with reason: "query_limit". A bootstrap too large to serialize is refused with reason: "snapshot_bytes". Errors and limits covers both.

Next

Scalable realtime is the wider question deltas are one answer to: what to do when one change has to reach a lot of people.

On this page