helipod
Core Concepts

Reactivity

What makes a subscribed query re-run, which query shapes get the cheapest live updates, and what happens on reconnect.

How it works covers the core idea: a subscribed query re-runs only when a committed write intersects its recorded read-set.

This page goes one level deeper, from the app developer's seat. You'll see exactly what makes a query re-run, which query shapes get the cheapest incremental updates, and why reconnecting after a dropped connection is nearly free.

Everything below describes the engine that ships today. If you want the internals (the data structures, the wire protocol, the source files), see the contributor-facing Reactivity & sync page instead.

Read-sets are range-precise, not table-level

When a query reads through ctx.db.query(table, index).eq(field, value), the engine doesn't record "this query touched the messages table." It records the exact slice of the index the scan covered. For .eq("conversationId", id), that's the single narrow span of the by_conversation index belonging to that one conversation. Adding .gt/.gte/.lt/.lte narrows it further. ctx.db.get(id) records a single point.

That precision is what Queries means when it says the shape of your query determines how it reacts: a narrow read range means only writes landing in that same narrow range can ever invalidate it. A busy app can commit thousands of writes to other conversations without your subscription doing any work at all.

An unbounded scan, say ctx.db.query("messages", "by_creation").collect(), still records a range: the full interval of that index. That's correct, not a penalty. A query that reads everything genuinely depends on everything, including rows that don't exist yet, so any write to the table has to re-run it. Scoping reads down to an index range is what keeps a query's reactive footprint small.

What triggers a re-run

A subscription re-runs when, and only when:

  1. Its query recorded a read-set (the ranges above) when it last ran.
  2. A mutation committed a write-set: the rows it inserted, replaced, or deleted. See Mutations.
  3. The two overlap.

Everything else, every live subscription with no overlap, is untouched. There's no polling, no periodic sweep, and no per-write cost proportional to the number of unrelated subscribers. The engine finds the affected subscriptions with an indexed matcher, so a write that affects one subscription out of ten thousand only pays for that one.

There's one fallback: a subscription that recorded no ranges at all matches at the table level instead, so any write to a table it read re-runs it. Coarse, but it can never under-report and silently miss a write.

Keep queries in these shapes for cheap updates

Being affected no longer always means "re-run the handler and resend the whole result." For a few provably safe query shapes, the engine derives the exact changed rows straight from the commit itself and sends just those rows as a diff. Your useQuery hook sees the same value either way. The difference is cost, not correctness.

You get the diff path when the query is one of these shapes:

  • A single ctx.db.get(id), returned as-is. A write to that one document becomes a single-row update on the wire.
  • A single .collect() over one index range, returned as-is, with no .take(). A write becomes an add, edit, or remove of just the affected rows. The wire cost is proportional to what changed, not to the size of the list, so the saving grows as the list grows.
  • A single .paginate() page, returned as-is. Same row-level diffs, pinned to the page's own key range, so writes elsewhere in the list don't touch it.

And you fall back to a full re-run and full resend (the exact behavior every query had before this optimization existed) when the query:

  • post-processes the result (.filter(), .map(), .slice(), a spread, any re-shaping),
  • makes more than one read (a get plus a collect, a join, reading twice),
  • uses .take() or hits a maxScan cap while paginating,
  • or reads tables with row-level read policies applied.

A performance layer, not a correctness contract

Write your queries however is natural for your app. A query that can't take the diff path still works exactly as it always has. Nothing about what your query returns ever depends on which path it takes. There's also no flag to force it: the engine proves safety per run, or falls back.

Every diffed update also carries a small checksum, computed independently on the server and the client. If they ever disagree, that one query quietly resyncs from a full re-run. Wrong data is never shown for more than a beat, and nothing else is affected.

Reconnect resume: coming back is nearly free

A client that drops offline and comes back (a laptop lid, a train tunnel) resubscribes to everything it was watching. Naively that would mean re-downloading every result just to learn most of them didn't change. Helipod avoids both the bytes and most of the work, automatically, with no configuration:

  • The bandwidth half. Every pushed result carries a content fingerprint. On reconnect, the client echoes back the last fingerprint it saw for each query. If the fresh result matches, the server answers with a tiny "unchanged" marker instead of the value. In the measured all-unchanged ceiling case this cuts reconnect bandwidth by roughly 99%.
  • The compute half. For a query the server can prove wasn't touched by any commit during the disconnect (it keeps each query's read-set indexed for about a minute after the last subscriber leaves), it skips re-executing the handler entirely. In the measured benchmark, 50 unchanged subscriptions re-execute zero handlers on reconnect; if exactly one was touched during the gap, exactly one re-runs.

Both halves are conservative: any doubt (an evicted entry, a write during the gap, a reconnect that lands on a different node in a multi-node fleet) falls back to a normal re-run. A resume is never allowed to serve stale data.

Why determinism makes this safe

Everything above (trusting a re-run, deriving a diff from a commit without re-reading the store, skipping a re-run on the strength of a timestamp) rests on one rule: a query is a pure function of the data it reads. Same rows in, same output out, every time.

That's why queries and mutations can't call fetch, read Date.now(), or call Math.random(). When you genuinely need the current time or a random value, ctx.now() and ctx.random() are the deterministic substitutes: both are fixed for the life of a transaction and any replay of it. Anything that can't be made deterministic, like a real network call, belongs in an action, which runs outside the transaction and is never part of a subscription.

The same determinism is what lets a mutation be safely replayed after an optimistic-concurrency conflict. See Mutations.

Honest boundaries

  • The compute-saving reconnect skip is per-node. The server keeps that bookkeeping in memory, so in a multi-node fleet a reconnect that lands on a different node re-runs its queries normally. Correct by falling back, never by guessing.
  • There's no way to force the diff path. The engine decides per run, from what it can prove about that run's read shape. The list of shapes above is the whole contract.
  • In a fleet, a write forwarded from another node re-runs affected queries in full rather than diffing them. This affects wire savings only, never correctness.

Measured

MetricBeforeAfter
Propagation p50, 10,000 live subscriptions, one affected per write6.72 ms0.24 ms (a 96% cut)
Wire bytes per update, live .collect() list2,647 B482 B (an 82% cut)
Wire bytes per update, live .paginate() page~2.6 KB475 B
Reconnect bandwidth, nothing changed while awayfull resendroughly 99% less
Handler re-executions on reconnect, 50 unchanged subscriptions500

Sources: the project CHANGELOG.md, versions 0.0.1 through 0.0.4, each with the benchmark scenario named. All figures are single-node, developer-laptop measurements. The shape of each win (cost proportional to what changed, re-execution eliminated) is what travels to other hardware, not the absolute numbers.

Going deeper

The machinery behind this page (the interval-indexed subscription matcher, the diff classifier and its identity checks, the drift checksum, the resume registry, and the wire protocol) is documented for contributors in Reactivity & sync.

On this page