Page Presence
elements man recipes/presence Read as markdownA 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 inapp/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 inlocalStorage, sent to the server as an explicitclientIdargument. It persists across reloads for that browser and never touches session state. For signed-in apps, denormalize the user's authenticated name intouserNamefrom the rpc body (read it fromsession.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
pagePathfrom the URL withreq.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/:idwith one identifier per list, not per visit).