---
title: Presence
description: "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:

```ts 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

```ts 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](/server/routes). 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](/client/react#bind-react-once):

```ts 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,
})
```

```tsx 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:

```ts
// 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:

```ts 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:

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