Reactive queries
A reactive query is a server-sidequery() handler that automatically
re-runs 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 is the “live joined query” shape Convex made famous. Pylon ships
the same model with native auth, multi-tenant policies, and self-host.
Writing a reactive query
Anyquery() handler is eligible — no opt-in flag, no special
decorator. The reactivity is unlocked at the call site by using
db.useReactiveQuery instead of a one-shot useFn / fetch.
ctx.db.* call this handler makes and
records the set of entities + row ids it touched. In the example
above the dep set is { Post: *, User: { authorId1, authorId2, ... } }.
Subscribing from React
reactive-subscribe over the WebSocket; the
server runs getFeed, captures deps, registers the sub, 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 only when it differs from the last value sent — so
a mutation that doesn’t change the rendered output costs one re-run
on the server but 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 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
subsequent re-run.
This is the only correct semantic. 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 — 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 dep as “entityPost, any row”. Mutations to any Post row dirty the sub. - Row-level:
ctx.db.get("User", "u_123")marks the dep as “entityUser, rowu_123”. Mutations to other User rows do NOT dirty the sub.
get + lookup, coarse for list +
query + search. Handlers that mix both get the union — list
reads contribute entity-level deps; targeted reads contribute
row-level deps on top.
Row-level deps are capped per subscription (default 256). Beyond
the cap, the sub falls back to entity-level matching. Prevents
runaway queries from bloating the registry.
Coalescing + re-run cadence
Multiple change events touching the same sub within one tick coalesce to a single re-run. The re-runner thread drains the dirty set on a notify-driven cadence (no fixed interval — wakes immediately when work arrives). Bursts of writes don’t cause per-event re-runs.When to use reactive queries vs entity queries
| Use case | Reactive query | Entity query (useQuery) |
|---|---|---|
| Server-side joins (entity + related rows) | ✅ | ❌ |
| Aggregations (count, sum, group-by) | ✅ | ❌ |
| Computed fields derived from multiple rows | ✅ | ❌ |
Simple where filter on one entity | ✅ | ✅ |
| Fully cached IndexedDB replica | ❌ | ✅ |
| Offline-first writes | Server roundtrip required | Optimistic UI works offline |
db.useQuery) when the screen is a list of
rows from one entity and the client can filter / sort / 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 subs 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’s no incremental dataflow
(Materialize / differential dataflow). For very expensive
handlers, the re-run cost is the full handler cost — design with
pagination /
limitto keep handlers fast. - The hash check skips client pushes when the result hashes identical. It doesn’t skip the re-run itself. A handler that reads 1k rows but produces a small derived value runs the full query on every dirty event.
- Reactive subs require the TS function runtime (Bun process). When
the runtime isn’t available (no
functions/directory), the hook receives aREACTIVE_UNAVAILABLEerror push and the consumer can fall back to a one-shot fetch.