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:

contracts.ts
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

app/api/canvas-events/route.ts
import { canvasServer } from '@/server/canvas'

export const GET = canvasServer.fetch
export const POST = canvasServer.fetch

This 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:

app/canvas/[canvasId]/session.ts
'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,
})
app/canvas/[canvasId]/canvas-client.tsx
'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 }) => null
  • setPresence(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, null included) into one trailing send. A 60Hz pointer becomes about fifteen sends per second. No ack, no confirmed, no retry, no rollback: nothing was ever true.
  • presence is the replicated map, Record<participantId, { [field]: { value, seen, at } }>, including your own participant. at is 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.moved

The 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:

server/canvas.ts
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:

BackendDelivery
store-redispush: TTL'd hash plus pub/sub patches
store-redis-httppush over HTTP subscriptions; adaptive polling when unsupported
store-memoryin-process, immediate
store-postgresnative notifications with listenConnectionString; adaptive polling by default
store-sqlitedegraded: 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.