Live Dashboard
elements man recipes/live-dashboard Read as markdownA dashboard that updates in real time as a background job recomputes its stats.
The job runs on a cron schedule, writes a single cached row, and notifies a
dashboard channel. Every browser on the dashboard page listens on that
channel; when the notification arrives, the template re-fetches the cached row
from the rpc and reassigns the value, and the rendered numbers update without a
page reload.
The pattern layers two ideas: the cron-driven aggregate cache from
elements man recipes cron-cache (same single-row stats table, same job
shape) plus a Channel push so connected browsers learn about new values as
soon as the job commits. The channel carries a "something changed" signal; the
actual value comes from an rpc the browser re-runs on each notification.
Migration
elements create migration 'add live stats' -tables=users,orders,stats
app/migrations/<timestamp>-add-live-stats.migration.sql:
-- add live stats
-- 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 users (
id uuid primary key default uuidGenerateV7(),
createdAt timestamptz not null default now(),
updatedAt timestamptz not null default now(),
handle text not null unique,
lastSeenAt timestamptz
);
create trigger usersTouchUpdatedAt
before update on users
for each row execute function touchUpdatedAt();
create table orders (
id uuid primary key default uuidGenerateV7(),
createdAt timestamptz not null default now(),
updatedAt timestamptz not null default now(),
userId uuid not null references users(id) on delete cascade,
amountCents integer not null
);
create index ordersCreatedAtIdx on orders (createdAt desc);
create trigger ordersTouchUpdatedAt
before update on orders
for each row execute function touchUpdatedAt();
create table stats (
id integer primary key default 1 check (id = 1),
computedAt timestamptz not null default now(),
totalUsers integer not null default 0,
activeNow integer not null default 0,
revenueTodayCents integer not null default 0
);
insert into stats (id) values (1);
The same single-row stats pattern as cron-cache. The columns are tuned for
"live" indicators: activeNow instead of weekly active, revenueTodayCents
instead of monthly. The cron fires more often (every 30 seconds) and the channel
pushes the change immediately.
Services
The module is shared because the channel has two callers: the /dashboard page
(subscribes via dashboardChannel.listen() and re-fetches on notify) and the
RefreshDashboardJob (calls dashboardChannel.notify(...) after each write). A
job is not a page, so the consumer count by the page-vs-shared rule is one page
plus one job. The channel is the bridge between them, so its declaration plus
the helpers that operate on the same row (Stats, loadStats, fetchStats)
cluster together in app/shared/services/. If the page never had a
server-driven refresh path (no job, no channel), the entire module would move to
app/pages/dashboard/services.ts.
app/shared/services/stats.ts:
import { Channel, sql } from "@elements/app";
export interface Stats {
computedAt: Date;
totalUsers: number;
activeNow: number;
revenueTodayCents: number;
}
export const dashboardChannel = new Channel<{ event: "refresh" }>("dashboard");
export function loadStats(): Stats {
return sql<Stats>(
`select computedAt, totalUsers, activeNow, revenueTodayCents
from stats where id = 1`,
).firstOrThrow();
}
/** @rpc */
export function fetchStats(): Stats {
return loadStats();
}
loadStats is a regular function used by the route's first render. fetchStats
is the rpc the browser calls on each notification. They run the same SQL but
with different call paths.
dashboardChannel is the broadcast channel. The job below notifies it after
every cache write; the page below listens.
Refresh job
elements create job RefreshDashboard
app/jobs/refresh-dashboard.ts:
import { Job, sql } from "@elements/app";
import { dashboardChannel } from "#app/shared/services/stats";
/** @job */
export class RefreshDashboardJob extends Job {
run() {
let row = sql<{
totalUsers: number;
activeNow: number;
revenueTodayCents: number;
}>(
`select
(select count(*)::int from users) as totalUsers,
(select count(*)::int from users where lastSeenAt > now() - interval '5 minutes') as activeNow,
(select coalesce(sum(amountCents), 0)::int from orders where createdAt >= date_trunc('day', now())) as revenueTodayCents`,
).firstOrThrow();
sql(
`update stats
set computedAt = now(),
totalUsers = ${row.totalUsers},
activeNow = ${row.activeNow},
revenueTodayCents = ${row.revenueTodayCents}
where id = 1`,
);
dashboardChannel.notify({ event: "refresh" });
}
}
The job runs the aggregates, writes the cached row, then notifies the channel.
The notify call is the last step, so every browser that receives it reads a row
that has already been updated. The event payload is intentionally minimal: the
channel only signals that a refresh happened; the new values come from
fetchStats, not the payload.
Page
elements create page dashboard
app/pages/dashboard/index.ts:
import dashboard from "./template";
import { dashboardChannel, loadStats } from "#app/shared/services/stats";
export default function route(req, res) {
return new dashboard({
initial: loadStats(),
listener: dashboardChannel.listen(),
});
}
The route returns the current cached stats for the initial server render plus a listener for the dashboard channel. First paint shows numbers immediately; the listener handles every subsequent update.
app/pages/dashboard/template.html:
import "./style.css";
import type { Listener } from "@elements/app";
import { Stats, fetchStats } from "#app/shared/services/stats";
function onNotify(stats: { value: Stats }) {
stats.value = fetchStats();
}
<html class="dashboard"
(initial: Stats,
listener: Listener<{ event: "refresh" }>,
private stats: { value: Stats } = { value: initial })
oninit={() => listener.on("notify", () => onNotify(stats))}>
<h1>live stats</h1>
<ul class="cards">
<li>
<span class="label">total users</span>
<span class="value">{stats.value.totalUsers.toLocaleString()}</span>
</li>
<li>
<span class="label">active now</span>
<span class="value">{stats.value.activeNow.toLocaleString()}</span>
</li>
<li>
<span class="label">revenue today</span>
<span class="value">${(stats.value.revenueTodayCents / 100).toLocaleString()}</span>
</li>
</ul>
<p class="freshness">last computed {stats.value.computedAt.toLocaleString()}</p>
</html>
stats is wrapped in { value: Stats } so the onNotify handler can reassign
stats.value from a function parameter. The template's oninit wires the
listener once; every subsequent notify event fires onNotify, which re-runs
the rpc and reassigns the value. Reading stats.value in the bindings tracks
reactively, so every dependent cell updates on each reassignment.
Routes
Register the page and the cron entry in index.ts:
import dashboard from "#app/pages/dashboard";
import { RefreshDashboardJob } from "#app/jobs/refresh-dashboard";
// ...
app.route("/dashboard", dashboard);
app.cron("every 30s", "refresh dashboard", () => new RefreshDashboardJob().schedule());
The cron fires every 30 seconds. Postgres arbitrates the leader across the deployment so exactly one machine schedules each tick.
Notes
- Why notify with
"refresh"and not the new values. The payload could carry the newStatsdirectly and skip the rpc round-trip. The"refresh"signal is more flexible: different listeners may want different views of the data (admin vs user, scoped vs global), and each re-fetch can apply its own auth or scope. For a single fixed view, inlining the payload is the simpler choice; do that by typing the channel asChannel<Stats>and passing the new row tonotify. - Why not LiveTable. LiveTable broadcasts row-level changes. The
statstable has one row that updates in place; the LiveTable's optimistic mutator and auto-fill add nothing here. The channel matches what's happening: one event per recompute, no row identity to track. - Cron cadence. 30-second updates are aggressive for a live dashboard. Tune
up to
every 5sfor high-frequency UIs or down toevery 5mif the data doesn't actually change that fast. The cost is a Postgres aggregate per tick; profile against your actual data volumes. - Push from anywhere. Anything on the server can call
dashboardChannel.notify(...). An order-fulfillment rpc that wants to bump the revenue counter immediately on commit cannotifyitself, bypassing the cron cadence. Pages still re-fetch through the same path, so the UX is identical regardless of who triggered the update. - Per-user dashboards. When each user sees their own numbers, the rpc reads
where userId = ${session.getOrThrow('userId')}(or similar) and the channel signal can stay global ("something changed; everyone re-fetch their own slice"). For tighter targeting, filter the listener:dashboardChannel.listen({ filter: (e) => e.userId === currentUserId }).