Ajo Kit
Live updates
Topics, SSE route payloads, the route cache and its scope.
A Kit page can stay current without polling and without a client-side store. Loaders name the data they read as topics; writes announce the topics they changed. Kit then re-runs the loaders of every open page that tracks those topics and sends the result over server-sent events. The same topics decide when the browser’s route cache can be trusted.
Track and emit topics
A topic is a plain string you choose, such as notes, note:42 or notes:7 for one person’s notes. A loader calls req.track?.() with one topic or an array. The optional call matters: track exists only while a page’s loaders run, not in actions or API handlers. Topics from layout and page loaders are combined for the whole route.
import type { ActionContext, Request, Response } from 'ajo-kit'
import { minLength, object, parse, pipe, string, trim } from 'ajo-kit/validate'
import { db } from '../database'
const Note = object({ title: pipe(string(), trim(), minLength(1)), body: string() })
export async function page(req: Request) {
req.track?.('notes')
const notes = await db()
.selectFrom('notes')
.select(['id', 'title', 'created'])
.orderBy('id', 'desc')
.limit(50)
.execute()
return { notes }
}
export const actions = {
async add(req: Request, _res: Response, action: ActionContext) {
const input = parse(Note, req.body)
await db().insertInto('notes').values(input).execute()
action.emit('notes')
return { ok: true }
},
}There are two ways to emit, and both take one topic or an array:
| Call | Where | Effect |
|---|---|---|
action.emit(topic) | Route actions, through their third argument | Broadcasts to live pages and adds the topics, with their new versions, to that action’s JSON response. |
emit(topic) from ajo-kit/server | API handlers, loaders and other server work | Broadcasts only. It never adds anything to an action response. |
Emit after the write is durable. For several statements, that means after the transaction commits, as shown in Database. Prefer a few precise topics over one broad catch-all: every page tracking an emitted topic runs its loaders again.
The live stream
After the first render and each client navigation, the browser opens an EventSource on the page’s own URL. The server runs the route’s middleware and loaders as for any page load. If the loaders tracked no topic, it answers 204 and no stream stays open, so pages without live data cost nothing.
When a topic is emitted, Kit waits a few milliseconds to batch further emits, then revalidates every open stream that tracks one of the changed topics, a few at a time. For each stream it:
- runs the route’s middleware again. If the credential no longer passes, or now belongs to someone else, the stream closes with an
expiredevent, and the client reloads the route’s data so the application’s guards can send the visitor elsewhere, such as to sign in; - runs the loaders again, and closes the stream if they no longer track any topic;
- sends nothing when the new payload hashes the same as the last one sent;
- otherwise sends the complete route payload.
{
"data": [
{ "title": "Notes" },
{},
{ "notes": [{ "id": 8, "title": "Groceries", "created": "2026-09-25 14:02:11" }] }
],
"hash": "1k3v0qz",
"topics": ["notes"],
"versions": { "notes": 4 },
"scope": "anon"
}data holds the merged head followed by one entry per layout and the page. The client replaces the active route’s data with it, applies the head and renders again. Every 30 seconds the stream also carries a comment as a heartbeat.
Full payloads, not patches
Kit does not compute differences. A live message carries the same head and loader data a navigation would fetch, and components render it from data like any other. That keeps one path for rendering, at the cost of sending every loader’s data again when anything in the route changes. Keep live loaders bounded, for example with limit().
Topic versions and early 304
Every emit increments a version counter per topic. Route responses carry the route’s hash, its sorted topics and their current versions. When the client requests a route it has cached, it sends that material back:
| Request header | Contents |
|---|---|
X-Have | The cached payload hash. |
X-Ajo-Versions | The cached topic versions, as a JSON object such as {"notes":4}. |
X-Ajo-Scope | The cache scope the entry was stored under. |
The server’s answer is marked with X-Ajo-Cache:
| Status | X-Ajo-Cache | Meaning |
|---|---|---|
| 304 | fresh | The scope matches and every presented version is current. The loaders did not run. |
| 304 | revalidated | Versions had moved, but the loaders produced the same hash. |
| 200 | miss | A new payload, or one the client did not have. |
Middleware runs before the early 304, so skipping loaders never skips authorization. A route that tracks no topics can still end in a hash-based 304, but never in the early one. Full page loads always render HTML.
The client route cache
The browser keeps recent route payloads in memory, keyed by URL, so going back to a page costs a 304 instead of a download. It holds at most 50 entries, each for up to 5 minutes. When it is full, the least recently used entry goes first, and the active page is never evicted by that pruning.
After a successful action, the client drops cached routes that track any topic the action emitted, along with entries that track no topics at all. An action that emits nothing clears the whole cache, because Kit cannot know what it changed. The cache is not a store: components always render the active route’s data.
Cache scope
Signing in and out are client navigations, not page reloads, so the same in-memory cache outlives a change of identity. Every entry is therefore partitioned by a scope, an opaque label the server derives for each request from the credential your authentication middleware attached: req.token, else req.session, else req.user. The identifier is hashed with its kind, so a token and a user that share an id never share a partition. Requests without a credential share anon.
The scope travels in the server-rendered document, in route JSON and in live messages. The client caches a payload only under the scope it was computed for, and drops the previous partition when the identity changes. The early 304 confirms a hash only when the presented scope matches the requester’s. Without a scope, nothing is cached: guessing wrong would show one person another person’s data.
To choose the partition yourself, set req.scope in a middleware. The label is not a secret; it only separates entries in one tab’s memory.
Reconciling after actions
A successful action that does not redirect dispatches an ajo:action event with its response. If the emitted topics intersect the active route’s topics, the client makes sure the page catches up. When the live stream is open, it waits briefly for the update to arrive over SSE; if none does, or the stream is not open, it reloads the route’s data as JSON. The page catches up either way; SSE only saves a request.
This is why actions should use action.emit(). Topics sent with the server-level emit() reach live streams but not the action response, so the client clears its whole cache and does not reconcile the page.
Connection limits
One process holds at most 128 live streams, and at most 8 for one identity: a bearer token, a session, a user, or for anonymous visitors their client address. Beyond that the server answers 503 or 429, both with Retry-After: 30, instead of opening a stream. The page keeps working; it reconciles through JSON after its own actions.
One process
Topic versions, open streams and pending updates live in the memory of one server process. The supported production topology is one engine process with one SQLite database, and a reverse proxy or process manager in front is fine as long as it keeps a single application process.
- ActionsWrite data and emit topics from route actions.
- LoadersRead server data and track what it depends on.
- DatabaseTransactions, migrations and data on disk.
Source of truth: ajo-kit architecture: route freshness and live updates