Live updates
Two people have your app open. One adds a row. The other sees it — without a refresh, a polling interval, a subscription, a cache key, or a line of code from you.
This page explains how, because a feature that works by magic is a feature you cannot debug.
#The mechanism
It rests on one observation: the platform already knows which tables a query read and which tables a mutation wrote, because every handler runs against a database handle that records exactly that.
- A query's response carries the list of tables its handler touched. The client remembers it, keyed by the operation name and its encoded input.
- A mutation's response carries the list of tables it wrote, and the kinds of write.
- When a mutation commits, the app pushes a small frame to every connected client over a server-sent events stream.
- Each client refetches only the queries whose remembered read-set intersects the frame's table list.
So a write to notes refreshes queries that read notes, and leaves the rest alone.
#What is on the wire
The frame carries the table names, the write kinds, and a monotonic revision number. It never carries
record data, ids, or anything about who made the change. A client learns "something in cards
changed" and asks its own questions through the ordinary query path, where the ordinary
authorization applies. That is the property that makes it safe to broadcast to everyone
at once: the notification is not a data channel.
#Three paths, not one
Server push is the interesting one, but it is not the only one, and that is deliberate — invalidation still works with the stream down.
| Path | Covers | Mechanism |
|---|---|---|
| Same tab | Your own writes | useMutation invalidates from the write set in the response |
| Same browser, other tabs | A second tab you have open | A BroadcastChannel message |
| Everyone else | Other users, other devices | The server-sent events stream |
Your own mutation therefore never pays a round trip to see its own effect.
#What you write
Nothing:
const notes = useQuery('notes.list', {});That is the whole opt-in. The stream opens when the first query mounts and closes when the last one
unmounts; connections are shared, so a page with twenty queries has one stream. On the server there is
no publish(), no channel to name, and no invalidation call — reactivity is derived from what your
handlers actually did, so it cannot be forgotten.
Watch it work with two browser windows on xeer dev, or one normal window and one private window signed
in as a different persona.
#Reliability
Every failure mode collapses to "refetch more than strictly necessary", never to "show stale data forever".
- A query whose read-set is not yet known refetches on every frame. Learning can only narrow the blast radius; it can never cause a miss.
- A dropped frame is caught by the revision number. On reconnect the client compares the revision it last saw; if it has jumped, or the app instance changed, it invalidates everything.
- A dropped connection reconnects with full-jitter exponential backoff, from 500 ms up to 30 seconds.
- Streams have a bounded lifetime and reconnect on expiry. A stream never outlives the identity assertion behind it, so a revoked session stops receiving frames.
- There is a connection budget.
liveConnectionsin your manifest defaults to 100 simultaneous streams and accepts up to 1000. Past it, a client is told to retry after an interval and honours it. Raise the budget if your app needs more; see the manifest reference.
A heartbeat every 20 seconds keeps intermediaries from closing an idle stream.
#What is not covered
Writes made through an endpoint() do not invalidate anything. Only
mutations report a write set. If your client writes by fetch-ing one of your endpoints, invalidate by
hand:
import { invalidateQueries } from '@impetik/xeer/client';
await fetch('/api/links', { method: 'POST', body: JSON.stringify({ url }) });
invalidateQueries(['links']);This is the one place the automatic story has a seam, and it is a seam on purpose: an endpoint is a raw HTTP handler, and the framework does not inspect what you did inside it.
#What this is not
- There is no
useSubscription, and no way to subscribe to a table directly. Invalidation is the only primitive; queries are the only way data reaches a client. - It is not a real-time transport. You cannot push a custom message to connected clients, and you cannot use it as a chat channel. Frames say only "these tables changed".
- It is not optimistic UI. A mutation resolves when it has committed, and the refetch follows. If
you want an optimistic render, hold the pending value in
useStateyourself.
Those are real limits. The trade is that the thing you get instead — every query, on every client, correct after every write — needs no configuration and cannot drift out of sync with your schema, because it is derived from your schema.
#Next
- The client —
useQuery,useMutation,invalidateQueries. - Server functions and the database — the reads and writes being tracked.
- Manifest reference — the
liveConnectionsbudget.