Project: Deno cron service
Write jobs as plain testable functions, trigger them with Deno.cron, and enforce idempotency with a unique execution key
This tutorial expands blueprint 3 into concrete steps. For Deno Deploy's timeline, database, and cron platform behavior see Databases, cron, and timelines; this page focuses on the service itself.
Goal and final shape
cron-service/
├── jobs/daily-report.ts # scheduling entrypoint: claim the execution key, call the service, record the outcome
├── services/report.ts # pure business logic, unaware of cron
├── main.ts # Deno.cron registration (module top level)
├── migrations/ # the job_runs table
└── deno.json
Core discipline: no business logic inside the Deno.cron() handler. A job is a plain async function that tests call directly; cron only triggers it on UTC time.
Setup
deno init cron-service
cd cron-service
deno add npm:postgres
The example stores execution records in PostgreSQL (the postgres driver is pure JavaScript and works directly in Deno). The connection string lives in the DATABASE_URL environment variable. With Deno KV instead, the idea is the same — claim a unique key with an atomic operation.
Milestone 1: a testable job
Business logic independent of scheduling
import type { Sql } from "postgres";
export async function buildDailyReport(sql: Sql, day: string) {
const rows = await sql`
select count(*)::int as orders, coalesce(sum(total), 0)::numeric as revenue
from orders
where created_at >= ${day}::date
and created_at < (${day}::date + interval '1 day')`;
return { day, orders: rows[0].orders, revenue: rows[0].revenue };
}
day is an explicit parameter, so tests can replay any date without waiting for a real schedule.
Idempotency: a unique execution key
The core problem: cron may retry, and a manual replay re-runs the same logical date. Register every execution in a table and "claim" it with a unique (job_name, execution_key) constraint:
create table job_runs (
id serial primary key,
job_name text not null,
execution_key text not null,
status text not null default 'running',
started_at timestamptz not null default now(),
finished_at timestamptz,
error text,
unique (job_name, execution_key)
);
import type { Sql } from "postgres";
import { buildDailyReport } from "../services/report.ts";
export async function runDailyReport(sql: Sql, day: string) {
const claimed = await sql`
insert into job_runs (job_name, execution_key)
values ('daily-report', ${day})
on conflict (job_name, execution_key) do update
set status = 'running', started_at = now(),
finished_at = null, error = null
where job_runs.status = 'failed'
returning id`;
if (claimed.length === 0) {
return { skipped: true, day }; // already succeeded or currently running; return immediately
}
try {
const report = await buildDailyReport(sql, day);
// Business writes use day as the idempotency key too: upsert
await sql`
insert into daily_reports (day, orders, revenue)
values (${day}, ${report.orders}, ${report.revenue})
on conflict (day) do update
set orders = excluded.orders, revenue = excluded.revenue`;
await sql`
update job_runs set status = 'ok', finished_at = now()
where id = ${claimed[0].id}`;
return report;
} catch (err) {
await sql`
update job_runs set status = 'failed', finished_at = now(),
error = ${String(err)}
where id = ${claimed[0].id}`;
throw err;
}
}
Both idempotency layers are required: job_runs prevents duplicate executions, and the daily_reports upsert guarantees that even if a retry lands after the business write but before the status update, a rerun only overwrites the same row.
Note the do update ... where status = 'failed' in the claim statement: with on conflict do nothing, a failed record would hold the unique key forever, and both platform retries and manual replays would be skipped as "already executed." Another edge is a permanent running row left behind when the process crashes before the status update — in production, add a reclamation rule that treats a started_at older than a threshold as an expired lease.
Milestone 2: register the cron job
import postgres from "postgres";
import { runDailyReport } from "./jobs/daily-report.ts";
const sql = postgres(Deno.env.get("DATABASE_URL")!);
function utcYesterday(): string {
const d = new Date(Date.now() - 86_400_000);
return d.toISOString().slice(0, 10);
}
Deno.cron(
"daily-report",
"0 3 * * *",
{ backoffSchedule: [60_000, 300_000, 900_000] },
async () => {
console.log(JSON.stringify({ job: "daily-report", day: utcYesterday() }));
await runDailyReport(sql, utcYesterday());
},
);
Key points (platform details in the Deploy docs):
- Schedules are UTC;
0 3 * * *means 03:00 UTC, not local time. - Failed executions are not retried by default. Opt in with an explicit
backoffSchedule: each array element is the millisecond delay before the next retry, with at most 5 retries and a 1-hour ceiling per delay. - The same job never runs concurrently: if the previous execution is still going (or a retry overlaps the next scheduled run), the later one is skipped. Long-running jobs do not pile up.
- Registration must happen at module top level, before the server starts; jobs declared inside request handlers or conditionals are not discovered by Deploy.
Test strategy
Don't test by waiting for the real schedule locally — call the function directly:
import { assertEquals } from "jsr:@std/assert";
import postgres from "postgres";
import { runDailyReport } from "./daily-report.ts";
const sql = postgres(Deno.env.get("TEST_DATABASE_URL")!);
Deno.test("re-running the same day executes only once", async () => {
// Clean the execution key first, so stale rows in the test database can't make the case pass vacuously
await sql`delete from job_runs where job_name = 'daily-report' and execution_key = '2026-08-01'`;
await sql`delete from daily_reports where day = '2026-08-01'`;
const first = await runDailyReport(sql, "2026-08-01");
const second = await runDailyReport(sql, "2026-08-01");
assertEquals((first as { skipped?: boolean }).skipped, undefined);
assertEquals((second as { skipped?: boolean }).skipped, true);
const rows = await sql`
select count(*)::int as n from daily_reports where day = '2026-08-01'`;
assertEquals(rows[0].n, 1);
});
Add two more cases: when the business write fails midway, job_runs records failed and a replay can recover; after replaying, daily_reports still holds exactly one row. Use a dedicated TEST_DATABASE_URL, separate from the development database.
Deployment and observability
- Deploy discovers
Deno.cron()definitions by evaluating top-level module code at deploy time, then takes over scheduling; rolling back a revision re-registers that revision's jobs. Production and each Git branch timeline fire independently — previews run jobs too, so tag logs withDENO_TIMELINEand skip external side effects outside production when needed. - Executions appear in the dashboard's Cron tab and in logs/traces (filter by
kind:cron,cron.name:); thejob_runstable answers the business questions: which day never ran, which run failed, what the replay produced. - Each cron execution is billed as one inbound HTTP request; free organizations are limited to 10 cron jobs per revision.
- Permissions: the service needs
--allow-net(database) and--allow-env=DATABASE_URL— never-A.
Acceptance checklist
- Jobs are plain functions with no Deno.cron dependency, called directly by tests
- Re-running the same execution key has no side effects (database unique constraint as backstop)
- Failure paths record status/error; replay recovers and the result stays unique
- backoffSchedule is configured explicitly instead of relying on defaults
- Cron registration sits at module top level; schedules are designed in UTC
- Production and preview timeline executions are distinguishable in logs and tables
- deno fmt --check, deno lint, deno check, and deno test all pass
Official references: Deno Deploy Cron, Timelines, postgres.js.