DocsProject blueprints

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 with DENO_TIMELINE and 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:); the job_runs table 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.

Type to search all documentation.