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.
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.
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.
| Rule | Why |
|---|---|
| A collection, not a global | A global is a single row |
operation: "find" | count and get have no row set to key |
No limit, offset or orderBy | A window makes membership depend on other rows |
No with | Relations are not tracked in delta mode |
columns must keep id | id is the delta key |
No RAW in where | The server cannot inspect raw SQL |
No relation field in where | Membership 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.
| Cap | Default | What it bounds |
|---|---|---|
maxDeltaFindLimit | 384 | Rows a delta topic may track |
maxBufferedDeltaEvents | 512 | Events queued for one group |
maxBufferedDeltaBytes | 1 MiB | Bytes 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.
Configuration
The optional second argument to createQuestpieQueryOptions sets the key prefix, the error mapping and a pinned locale or stage for the whole proxy.
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.