Presence
Ephemeral participant state on a durable session: cursors, typing, live progress. Presence repaints; the log replays.
Two planes, one session
Events are facts: stored, folded by reducers, replayed on demand. Presence is not. "Her cursor is at (312, 88)" is useful for about two seconds, has no replay value, and a lost update is repainted by the next one. The test:
Will anyone, human or reducer, care about this datum after the moment passes? Yes: event, in the log. No: presence.
Messages, document updates, cancellations: events. Cursors, selections, typing, "who's online", a handler's live progress: presence.
Presence rides the session you already have: same contract, route, auth, and
stream. A2 replicates each participant's latest field values to every
subscriber and stores none of them in the log. Presence never appears in
history(), state(), or a reducer's input type; never arms the scheduler,
takes a claim, retries, or blocks an append; and never enters the browser
cache.
Presence repaints; the log replays. Anything that decides (a fold, a retry, an abort match) reads the log. Presence only hurries pixels.
Declare it in the contract
presence is a second vocabulary next to events, one schema per field of a
participant's state:
import { z } from 'zod'
import * as a2 from 'experimental-a2'
export const canvas = a2.contract({
name: 'canvas',
events: {
'shape.created': z.object({
id: z.string(),
kind: z.enum(['rect', 'ellipse']),
x: z.number(),
y: z.number(),
}),
'shape.moved': z.object({ id: z.string(), x: z.number(), y: z.number() }),
},
reducer: { name: 'scene', initialState: {}, fold: (state) => state },
presence: {
cursor: z.object({ x: z.number(), y: z.number() }),
dragging: z.object({ shapeId: z.string(), x: z.number(), y: z.number() }),
},
})The field is the unit of replication: last writer wins per participant per
field, so setting cursor leaves dragging alone. Fields are absent until
set. They clear when set to null (pointer leaves the canvas, blur, unmount)
or when their TTL lapses, so a closed tab disappears by silence, with no
explicit leave. The TTL is 60 seconds by default; set
createServer({ presence: { ttlMs } }) to change it. Expiry counts on the
backend's clock, not the sender's stamp.
The route
import { canvasServer } from '@/server/canvas'
export const GET = canvasServer.fetch
export const POST = canvasServer.fetchThis is the usual server.fetch route. A contract with
presence fields streams presence patches with events, starting with a snapshot
of the current map, and its push body accepts an optional presence sibling
to events, forwarded to setPresence. authorize sees the complete
presence patch, including its caller-supplied participant id, over HTTP and
WebSocket.
setPresence validates each field against the contract, then broadcasts: no
append, no dispatch, no log row. A bad field throws INVALID_PAYLOAD and an
unknown one throws UNKNOWN_PRESENCE_FIELD, broadcasting nothing. A
presence-only push acks [].
The browser
Presence needs a participant id, yours to mint: a user id, a tab nonce. Set
it on createClient({ participant }) when the app knows it at module scope,
or on the Provider, whose participant prop overrides the client's. The
session module is the usual pair:
'use client'
import { createClient } from 'experimental-a2/client'
import { createReact } from 'experimental-a2/react'
import { canvas } from '@/contracts'
export const canvasClient = createClient({
contract: canvas,
api: '/api/canvas-events',
})
export const { SessionProvider, useSession } = createReact({
client: canvasClient,
})'use client'
import { useSession } from './session'
export function CanvasClient() {
const { presence, setPresence } = useSession()
return (
<div
onPointerMove={(e) => setPresence({ cursor: { x: e.clientX, y: e.clientY } })}
>
{Object.entries(presence).map(([participant, fields]) =>
// render-time expiry: a cursor silent for a few seconds is gone
fields.cursor && Date.now() - fields.cursor.at.getTime() < 5_000 ? (
<Cursor key={participant} {...fields.cursor.value} />
) : null,
)}
</div>
)
}
// your cursor sprite:
// function Cursor({ x, y }: { x: number; y: number }) { ... }
const Cursor = (props: { x: number; y: number }) => nullsetPresence(values)is fire-and-forget. It validates locally, merges into the local map immediately, and coalesces on the wire: the first set sends immediately, and later values within the interval merge per field (latest wins,nullincluded) into one trailing send. A 60Hz pointer becomes about fifteen sends per second. No ack, noconfirmed, no retry, no rollback: nothing was ever true.presenceis the replicated map,Record<participantId, { [field]: { value, seen, at } }>, including your own participant.atis the sender's stamp for when the value was set and the last-writer-wins comparator. The client stamps sends monotonically, so overlapping sends never invert. How long to paint a value is view logic, decided at render time.
Both members are typed from the contract's presence map, with no type
arguments. On a contract without presence they do not exist, so reaching
for them is a compile error.
Reconciling the planes: seen
Every presence value carries seen, the sender's stream frontier when it was
set, stamped automatically. It orders the two planes without wall clocks.
The classic race: a participant drags a shape (presence), drops it (event),
and a stale dragging patch arrives after the durable shape.moved. Without
a shared order, the shape flickers backward. With seen, superseding is one
render-time rule:
// in render code: a ghost is dead once the log has moved past it.
// show fields.dragging only if
// fields.dragging.seen >= state.shapes[fields.dragging.value.shapeId].movedAt
// where movedAt is the index your reducer recorded for the last shape.movedThe durable event at index 214 beats any presence stamped 213 or lower. A2 carries the stamp; the comparison is yours, in view code.
Handlers broadcast too
ctx.session.setPresence is the same operation, so a handler can publish live
progress without writing non-facts to the log:
import { createServer } from 'experimental-a2/server'
import { canvas } from '@/contracts'
export const canvasServer = createServer({
contract: canvas,
handlers: {
'shape.created': async (ctx) => {
// long work; broadcast progress as it goes:
// await ctx.session.setPresence({
// participant: 'system:thumbnails',
// values: { progress: { step: 2, of: 5 } },
// })
},
},
})The participant id is the handler's choice; a system: prefix keeps robots
apart from humans in the map. When the work completes, append the outcome as
an event: the progress was never a fact, the result is.
Backends
Declaring presence requires a store with the presence capability;
otherwise createServer throws PRESENCE_NOT_SUPPORTED. Every built-in
store has it, at two tiers:
| Backend | Delivery |
|---|---|
store-redis | push: TTL'd hash plus pub/sub patches |
store-redis-http | push over HTTP subscriptions; adaptive polling when unsupported |
store-memory | in-process, immediate |
store-postgres | native notifications with listenConnectionString; adaptive polling by default |
store-sqlite | degraded: bounded TTL'd rows, read on the live feed's poll cadence |
Degraded means later, not lost while watched. Presence-only traffic re-reads adaptively, from 25ms backing off to two seconds while unchanged; new patches or events restore the fast cadence. An idle remote viewer may wait up to two seconds plus query latency for the first change. Cursors want the push tier; typing indicators and progress read fine on either.
Redis Pub/Sub does not broadcast TTL expiry: silent departures keep their last values in connected maps until a snapshot reconciles them, so expire at render time as above. Native Postgres subscriptions reconcile missing or expired fields on their ten-second safety reads.
Presence rows are bounded per session and participant, so they are not log growth in disguise. On metered backends every patch is still a network operation.