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 client code from you.
This page explains how, because a feature that works by magic is a feature you cannot debug.
#Turn it on first
Server push is off unless you ask for it. Declare a connection budget in xeer.app.json:
{
"budgets": { "liveConnections": 100 }
}Redeploy, and everything below this section applies. Without it your app still invalidates its own queries in the writing tab and across tabs in the same browser — the first two paths in the table below — but a write by one person does not reach anyone else.
It is off by default because it is not free. One browser tab holding a live query keeps your app's state object resident continuously, and that is billed by duration whether or not anything is happening. An app that does not need cross-user push should not pay for it, and most do not. An app that does needs one line.
To turn it off again for a single app, set the budget back to 0 or remove it and redeploy. That is
the fastest remedy available and it affects nothing else.
#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 one of three. 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 beyond the budget above:
const notes = useQuery('notes.list', {});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.
With no budget declared, no stream is opened at all: the client does not attempt the connection, so there is nothing retrying in the background and nothing to see in the network panel.
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 to0— off — and accepts up to 1000. Past a declared budget, a client is told to retry after an interval and honours it. With the budget at0the app answers a terminallive_disabledinstead, which is not a retry signal: a current client stops asking. 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 you have to invalidate by hand: 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.