Cron Cache
elements man recipes/cron-cache Read as markdownStats that would be expensive to compute on every request, cached in a
single-row table. A cron job recomputes the row every five minutes; pages read
the cached row with one cheap select. The pattern decouples the expensive
aggregate work from the request path, so user-facing latency stays predictable
even as the underlying data grows.
The recipe shows a "site stats" panel: total user count, active-this-week count,
revenue this month. The aggregates run inside a RefreshStatsJob triggered by
app.cron, which writes the latest values into a single stats row keyed by a
fixed id. The dashboard route reads that one row.
Migration
elements create migration 'add stats' -tables=users,orders,stats
app/migrations/<timestamp>-add-stats.migration.sql:
-- add 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,
activeThisWeek integer not null default 0,
revenueThisMonthCents integer not null default 0
);
insert into stats (id) values (1);
The stats table holds exactly one row. The check (id = 1) constraint plus
default 1 ensures every insert and every update operates on the same row. The
seed insert in the migration creates that row, so the first page load doesn't
have to handle an empty cache. The cron job below upserts the same row by id.
Stats job
elements create job RefreshStats
app/jobs/refresh-stats.ts:
import { Job, sql } from "@elements/app";
/** @job */
export class RefreshStatsJob extends Job {
run() {
let row = sql<{
totalUsers: number;
activeThisWeek: number;
revenueThisMonthCents: number;
}>(
`select
(select count(*)::int from users) as totalUsers,
(select count(*)::int from users where lastSeenAt > now() - interval '7 days') as activeThisWeek,
(select coalesce(sum(amountCents), 0)::int from orders where createdAt >= date_trunc('month', now())) as revenueThisMonthCents`,
).firstOrThrow();
sql(
`update stats
set computedAt = now(),
totalUsers = ${row.totalUsers},
activeThisWeek = ${row.activeThisWeek},
revenueThisMonthCents = ${row.revenueThisMonthCents}
where id = 1`,
);
}
}
The job runs three aggregates in one SQL round-trip and then a single update
against the cached row. Each aggregate has its own where clause, which keeps the
query readable and gives Postgres independent indexes to use. The job is
fieldless (extends Job with no <Fields> type parameter) because the cron
tick is the only trigger; no payload to pass.
Dashboard page
elements create page dashboard
app/pages/dashboard/services.ts:
export interface Stats {
computedAt: Date;
totalUsers: number;
activeThisWeek: number;
revenueThisMonthCents: number;
}
app/pages/dashboard/index.ts:
import { sql } from "@elements/app";
import dashboard from "./template";
import { Stats } from "./services";
export default function route(req, res) {
let stats = sql<Stats>(
`select computedAt, totalUsers, activeThisWeek, revenueThisMonthCents
from stats where id = 1`,
).firstOrThrow();
return new dashboard({ stats });
}
The route does one select against a single-row table. The query is
constant-time regardless of how many users or orders the app has accumulated.
The page renders with the values as of the last cron tick.
app/pages/dashboard/template.html:
import "./style.css";
import { Stats } from "./services";
<html class="dashboard" (stats: Stats)>
<h1>site stats</h1>
<ul class="cards">
<li>
<span class="label">total users</span>
<span class="value">{stats.totalUsers.toLocaleString()}</span>
</li>
<li>
<span class="label">active this week</span>
<span class="value">{stats.activeThisWeek.toLocaleString()}</span>
</li>
<li>
<span class="label">revenue this month</span>
<span class="value">${(stats.revenueThisMonthCents / 100).toLocaleString()}</span>
</li>
</ul>
<p class="freshness">last computed {stats.computedAt.toLocaleString()}</p>
</html>
The template renders the cached values plus the computedAt timestamp so the
reader knows how stale the snapshot is.
Routes
Register the page and the cron entry in index.ts:
import dashboard from "#app/pages/dashboard";
import { RefreshStatsJob } from "#app/jobs/refresh-stats";
// ...
app.route("/dashboard", dashboard);
app.cron("every 5m", "refresh site stats", () => new RefreshStatsJob().schedule());
The cron entry fires every five minutes; the callback schedules a
RefreshStatsJob. Postgres arbitrates the cron leader so exactly one machine
fires each tick across the whole deployment. The job itself runs through the
standard worker pool, with retries and timeouts handled by the job system.
Notes
- Why one row, not one row per snapshot. Storing every snapshot would grow
the
statstable without bound and force the route to find the latest row on every read. The single-row pattern bounds storage and makes the read a constant-id lookup. If history matters, write to a separatestatsHistorytable from the job in addition to updatingstats. - Why a job, not raw SQL in the cron callback. The cron callback is supposed
to be a one-liner that schedules a job. Job retries, timeouts, and worker-pool
scaling all kick in for the actual aggregation. If
RefreshStatsJob.run()errors, the job system retries with exponential backoff; an error inside the cron callback itself would not retry. - Picking the cron cadence. Five minutes is a balance: fresh enough for a
dashboard, infrequent enough that the aggregates stay cheap. For per-second
freshness, see
elements man recipes live-dashboard, which adds a channel and reactive re-fetch on top of this same pattern. For nightly summaries, drop toevery day at 2am. - Initial value. The migration inserts the seed row so the first page load
works before the cron has ever fired. Without the seed, the route would throw
NotFoundErroruntil the first tick lands. - Per-tenant stats. Replace the
check (id = 1)constraint with atenantId uuid primary keyforeign key. The job loops over tenants and writes one row per tenant. Pages read bywhere tenantId = ${session.get('tenantId')}.