Reactive queries
A reactive query is a server-sidequery() handler that re-runs
automatically whenever the data it reads changes. The client subscribes
once. The server pushes a new result every time a mutation touches
anything in the handler’s dependency set.
This pattern is called a live joined query. Convex introduced it. Pylon
uses the same model, with native auth, multi-tenant policies, and
self-hosting built in.
Writing a reactive query
Anyquery() handler is eligible. It needs no opt-in flag and no
special decorator. Call db.useReactiveQuery instead of a one-shot
useFn call or fetch to enable it at the call site.
ctx.db.* call this handler makes and records
the set of entities and row ids it touched. In the example above, the
dependency set is { Post: *, User: { authorId1, authorId2, ... } }.
Subscribing from React
reactive-subscribe over the WebSocket. The
server runs getFeed, captures dependencies, registers the subscription,
and pushes the initial result.
After that, every server-side mutation touching Post or a User row in
the captured set triggers a re-run. The server hashes the new result and
pushes it only when it differs from the last value sent. A mutation that
does not change the rendered output still costs one re-run on the
server, but causes no client re-render.
On unmount, the hook sends reactive-unsubscribe. The server drops the
subscription and stops re-running.
Auth context across re-runs
The handler always runs under the subscriber’s auth context on every re-run, never the mutating user’s. A policy likeauth.userId == row.ownerId applied at first run applies on every later
re-run.
This is the only correct behavior. If re-runs used the mutating user’s
auth, a Stripe webhook (running as an elevated admin) would re-evaluate
getFeed with admin privileges and push the unfiltered result to a
logged-in user. That would be a silent read-policy bypass.
Dependency tracking granularity
The runtime tracks two levels:- Entity-level: any read of
ctx.db.list("Post")orctx.db.query("Post", {...})marks the dependency as “entityPost, any row”. Mutations to any Post row dirty the subscription. - Row-level:
ctx.db.get("User", "u_123")marks the dependency as “entityUser, rowu_123”. Mutations to other User rows do not dirty the subscription.
get and lookup, and coarse for
list, query, and search. Handlers that mix both get the union: list
reads add entity-level dependencies, and targeted reads add row-level
dependencies on top.
Row-level dependencies are capped per subscription (default 256). Beyond
the cap, the subscription falls back to entity-level matching. This cap
stops runaway queries from bloating the registry.
Coalescing and re-run cadence
Multiple change events touching the same subscription within one tick coalesce to a single re-run. The re-runner thread drains the dirty set on a notify-driven cadence, with no fixed interval. It wakes immediately when work arrives. Bursts of writes do not cause per-event re-runs.When to use a reactive query instead of an entity query
Use a reactive query when the value rendered on screen comes from a
function that touches multiple entities, or computes something the
client cannot easily derive from cached rows. Use an entity query
(
db.useQuery) when the screen shows a list of rows from one entity and
the client can filter, sort, or paginate locally for free.
Cross-machine support
Reactive queries work on multi-machine deployments. The runtime forwards change events into the registry from both the local mutation path and the cluster bus subscriber (see horizontal scaling). A mutation on machine A dirties subscriptions on machine B. B’s re-runner runs the handler and pushes the result to its connected client.Limits
- One handler call per re-run. There is no incremental dataflow
(Materialize or differential dataflow). For expensive handlers, the
re-run cost equals the full handler cost. Use pagination or a
limitto keep handlers fast. - The hash check skips the client push when the new result hash matches the last one sent. It does not skip the re-run itself. A handler that reads 1,000 rows but produces a small derived value still runs the full query on every dirty event.
- Reactive subscriptions require the TS function runtime (Bun process).
When the runtime is not available (no
functions/directory), the hook receives aREACTIVE_UNAVAILABLEerror push, and the consumer can fall back to a one-shot fetch.