# Page Presence A live "who's here" list for a page. Each browser sends a heartbeat rpc when the user identifies themselves, then again every 30 seconds. The heartbeat upserts a row in `pagePresence` keyed by `(clientId, pagePath)`. A cron job runs every minute and prunes rows whose `lastSeenAt` is older than two minutes. Heartbeats and prunes both notify a `presence` channel filtered per page; every browser on that page re-fetches the user list when the notification arrives. The `clientId` is an app-generated id the browser mints once and keeps in `localStorage`, then passes explicitly to the server on every heartbeat. Presence needs a stable per-browser key, but a session only exists after login, so the app owns this id rather than the session. See the note at the end for why. Three Elements primitives compose here: `Channel` for the change notifications, the database for the durable list of who's currently present, and `app.cron` for the scheduled cleanup. ## Migration ```bash elements create migration 'add page presence' -tables=pagePresence ``` `app/migrations/-add-page-presence.migration.sql`: ```sql -- add page presence -- Auto-update updatedAt on row changes. create or replace function touchUpdatedAt() returns trigger language plpgsql as $$ begin new.updatedAt = now(); return new; end; $$; create table pagePresence ( id uuid primary key default uuidGenerateV7(), createdAt timestamptz not null default now(), updatedAt timestamptz not null default now(), clientId text not null, pagePath text not null, userName text not null, lastSeenAt timestamptz not null default now(), unique (clientId, pagePath) ); create index pagePresencePagePathIdx on pagePresence (pagePath); create trigger pagePresenceTouchUpdatedAt before update on pagePresence for each row execute function touchUpdatedAt(); ``` The unique constraint on `(clientId, pagePath)` enforces one row per browser per page. A bare `insert` for the same key would raise `SqlError` from the database. The heartbeat rpc below pairs the constraint with an `on conflict (clientId, pagePath) do update` clause, which catches the conflict and turns the second call into an update of the existing row's `userName` and `lastSeenAt`. That pairing is what makes the heartbeat idempotent. `pagePresencePagePathIdx` keeps the per-page query fast as the table grows. ## Channel and services `app/shared/services/presence.ts`: ```ts import { Channel, sql } from "@elements/app"; export interface Presence { id: string; clientId: string; pagePath: string; userName: string; lastSeenAt: Date; } export interface PresenceEvent { pagePath: string; } export const presence = new Channel("presence"); export function listPresent(pagePath: string): Presence[] { return sql( `select id, clientId, pagePath, userName, lastSeenAt from pagePresence where pagePath = ${pagePath} and lastSeenAt > now() - interval '2 minutes' order by createdAt asc`, ).all(); } /** @rpc */ export function heartbeat(pagePath: string, clientId: string, userName: string): Presence[] { let trimmed = userName.trim(); if (trimmed.length === 0) { return listPresent(pagePath); } let existed = !sql( `select 1 from pagePresence where clientId = ${clientId} and pagePath = ${pagePath}`, ).empty(); sql( `insert into pagePresence (clientId, pagePath, userName) values (${clientId}, ${pagePath}, ${trimmed}) on conflict (clientId, pagePath) do update set userName = excluded.userName, lastSeenAt = now()`, ); if (!existed) { presence.notify({ pagePath }); } return listPresent(pagePath); } ``` The heartbeat takes `clientId` as an argument. The browser passes its own id on every call. `listPresent` is a shared helper that both the route and the rpc use. The rpc skips the channel notify when the heartbeat was an update (the user was already on the page); only fresh joins broadcast. Departures are broadcast by the cron job below. ## Prune job ```bash elements create job PrunePagePresence ``` `app/jobs/prune-page-presence.ts`: ```ts import { Job, sql } from "@elements/app"; import { presence } from "#app/shared/services/presence"; /** @job */ export class PrunePagePresenceJob extends Job { run() { let stale = sql<{ pagePath: string }>( `delete from pagePresence where lastSeenAt < now() - interval '2 minutes' returning pagePath`, ).all(); let paths = new Set(stale.map((row) => row.pagePath)); for (let path of paths) { presence.notify({ pagePath: path }); } } } ``` The `returning pagePath` clause collects the affected pages. The job dedupes them with a `Set` and notifies the channel once per page, so every page with a departure refreshes its list. A two-minute stale threshold gives a 30-second heartbeat four chances to land before a user is considered gone. ## Example page The recipe layers presence onto a single example page at `/`. The same template snippet drops into any page; the path parameter is what distinguishes one channel of presence from another. ```bash elements create page home ``` `app/pages/home/index.ts`: ```ts import home from "./template"; import { presence, listPresent } from "#app/shared/services/presence"; export default function route(req, res) { let pagePath = req.url.split("?")[0]; return new home({ pagePath, initialUsers: listPresent(pagePath), listener: presence.listen({ filter: (event) => event.pagePath === pagePath }), }); } ``` The route filters the listener so the browser only receives notifications for its own page. The initial user list is server-rendered, so the page's first paint already shows everyone present. `app/pages/home/template.html`: ```html import "./style.css"; import type { Listener } from "@elements/app"; import { Presence, PresenceEvent, heartbeat, } from "#app/shared/services/presence"; function getClientId(): string { let id = localStorage.getItem("presenceClientId"); if (!id) { id = crypto.randomUUID(); localStorage.setItem("presenceClientId", id); } return id; } function onTick( pagePath: string, clientId: string, userName: string, users: { value: Presence[] }, ) { if (userName.trim().length === 0) { return; } users.value = heartbeat(pagePath, clientId, userName); } function setup( pagePath: string, clientId: { value: string }, userName: { value: string }, users: { value: Presence[] }, listener: Listener, ) { clientId.value = getClientId(); listener.on("notify", () => onTick(pagePath, clientId.value, userName.value, users)); setInterval(() => onTick(pagePath, clientId.value, userName.value, users), 30_000); } , private clientId: { value: string } = { value: "" }, private userName: { value: string } = { value: "" }, private users: { value: Presence[] } = { value: initialUsers }) oninit={() => setup(pagePath, clientId, userName, users, listener)}>

who's here

onTick(pagePath, clientId.value, userName.value, users)}>
  • {u.userName}
  • no one here yet.
``` `setup` runs on mount: it reads (or mints) the client id from `localStorage`, wires the channel listener, and starts the 30-second timer. The user types a name and clicks "join"; that fires the first heartbeat. Both the timer and the form call `onTick`, which sends another heartbeat with the client id and reassigns the user list from the rpc's return value. `clientId`, `userName`, and `users` are wrapped in `{ value }` so the handler functions can write the new value through the wrapper object instead of reassigning a function parameter (which would not flow back to template state). ## Routes Register the page and the cron job in `index.ts`: ```ts import home from "#app/pages/home"; import { PrunePagePresenceJob } from "#app/jobs/prune-page-presence"; // ... app.route("/", home); app.cron("every 1m", "prune stale page presence", () => new PrunePagePresenceJob().schedule()); ``` The `/** @job */` build tag on the class declaration registers the job with the worker pool. There is no `app.job(...)` call; jobs are discovered at build time. ## Notes - **Adding presence to another page.** The whole pattern is reusable. Any page route can call `listPresent(req.url.split("?")[0])` and pass the same listener filter. The template snippet (the form plus the `
    `) is the same. A `` template in `app/shared/templates/` is a natural next refactor. - **Heartbeat-only-on-change.** The cron job notifies on departure. The heartbeat rpc notifies only on first join, not on subsequent heartbeats for the same client. That keeps the channel quiet during steady state: notifications fire when the user list changes, not on every tick. - **The client id, not the session.** A session in Elements exists only after login. An anonymous visitor has no session row and no cookie. Presence needs a stable key for signed-in and anonymous browsers alike, so the app mints one: `crypto.randomUUID()` stored in `localStorage`, sent to the server as an explicit `clientId` argument. It persists across reloads for that browser and never touches session state. For signed-in apps, denormalize the user's authenticated name into `userName` from the rpc body (read it from `session.get('userName')` on the server) instead of taking it from the form. - **Departure UX is up to the cron interval.** With "every 1m" cron and a two-minute stale threshold, a closed tab disappears from the list within two minutes at the latest. Tighter timings (cron every 10s, threshold 30s) cost more rpc round-trips and Postgres writes; loosen them for low-traffic apps. - **Path normalization.** The route extracts `pagePath` from the URL with `req.url.split("?")[0]`. Different query strings on the same path count as the same page. Mount the heartbeat under a logical "room" identifier instead of the literal URL if your app has dynamic segments that shouldn't fragment presence (`/lists/:id` with one identifier per list, not per visit).