Manual Recipes Page Presence

Page Presence

elements man recipes/presence Read as markdown

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

elements create migration 'add page presence' -tables=pagePresence

app/migrations/<timestamp>-add-page-presence.migration.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:

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<PresenceEvent>("presence");

export function listPresent(pagePath: string): Presence[] {
  return sql<Presence>(
    `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

elements create job PrunePagePresence

app/jobs/prune-page-presence.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.

elements create page home

app/pages/home/index.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:

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<PresenceEvent>,
) {
  clientId.value = getClientId();
  listener.on("notify", () => onTick(pagePath, clientId.value, userName.value, users));
  setInterval(() => onTick(pagePath, clientId.value, userName.value, users), 30_000);
}

<html class="home"
      (pagePath: string,
       initialUsers: Presence[],
       listener: Listener<PresenceEvent>,
       private clientId: { value: string } = { value: "" },
       private userName: { value: string } = { value: "" },
       private users: { value: Presence[] } = { value: initialUsers })
      oninit={() => setup(pagePath, clientId, userName, users, listener)}>
  <h1>who's here</h1>

  <form onsubmit={() => onTick(pagePath, clientId.value, userName.value, users)}>
    <input type="text" value={userName.value} placeholder="your name" required>
    <button type="submit">join</button>
  </form>

  <ul class="present">
    <li e:for={u of users.value}>{u.userName}</li>
    <li e:if={users.value.length === 0}>no one here yet.</li>
  </ul>
</html>

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:

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 <ul class="present">) is the same. A <PresentList> 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).