Background Jobs

Dune includes a cron-based background job system for recurring server-side tasks — digest emails, sitemap rebuilds, data synchronisation, cache warm-ups, and similar work.

Defining a job

Create a TypeScript file in your site's jobs/ directory:

jobs/
  weekly-digest.ts
  sitemap-rebuild.ts

Each file exports a schedule string constant and a default-exported handler function — not a single default-exported JobDefinition object. The loader (src/jobs/scanner.ts) reads mod.schedule and mod.default as two separate exports; mod.default must be a function, so a default-exported object (even one satisfying JobDefinition) fails the typeof handler !== "function" check and the job is skipped with a jobs.load.missing_handler warning:

// jobs/weekly-digest.ts
import type { JobContext } from "@dune/core/jobs";

export const schedule = "0 8 * * 1";   // every Monday at 08:00 — required, top-level export

export default async function handler(ctx: JobContext) {
  const recent = ctx.content.pages
    .filter((p) => p.published && p.date)
    .sort((a, b) => (b.date ?? "").localeCompare(a.date ?? "")) // date is a string, not a number
    .slice(0, 5);

  await ctx.email.send({
    to: "team@example.com",
    subject: "Weekly digest",
    template: "digest",
    data: { pages: recent },
  });

  ctx.logger.info("digest_sent", { count: recent.length });
}

The job's name is derived from the filename stem (weekly-digest.ts → "weekly-digest") — there's no separate name field to set. JobDefinition (the internal type the loader constructs from your two exports) does have name/schedule/handler fields, but you never author one directly:

Field Type Description
name string Derived from the filename, not something you export.
schedule string Standard 5-field cron expression (min hour dom month dow) — your export const schedule.
handler (ctx: JobContext) => Promise<void> | void Your default export.
timeoutMs number Optional per-job override of the default 10-minute timeout — export const timeoutMs = ... alongside schedule. JobSchedulerConfig.defaultTimeoutMs overrides the default globally.

JobContext

Property Type Description
content DuneEngine The raw content engine. .pages is a plain array property (not a method), .loadPage(sourcePath) loads one full page.
contentApi ContentApi The friendlier query API — .pages(), .page(), .search(), .taxonomy() — the same instance bootstrap() returns as contentApi. Always present, since jobs only run after bootstrap has fully completed. Added in 0.31.7.
config DuneConfig Merged site and system configuration.
storage StorageAdapter Raw storage adapter for plugin-specific reads/writes.
logger JobLogger Structured logger. Entries include the job name automatically.
email EmailClient Transactional email client. Present when an email provider is configured. Guard with ctx.config.site.email?.provider if email may not be set up.
signal AbortSignal Aborted when the job hits its timeout (default 10 minutes, or timeoutMs). Pass it to fetch() and other cancellable calls to actually stop work on timeout — JS has no true cancellation otherwise.

content and contentApi are two different objects, not one renamed — kept deliberately separate so existing jobs relying on content's exact array-property shape don't break. Prefer contentApi for anything beyond iterating every page; .search()/.taxonomy()/filtered .pages() aren't available on the raw engine.

Enabling jobs in site.yaml

Jobs must be explicitly declared in site.yaml before they are loaded:

jobs:
  - ./jobs/weekly-digest.ts
  - ./jobs/sitemap-rebuild.ts

Only the listed files are loaded. Files present in jobs/ that are not declared are ignored — this prevents arbitrary code execution if unexpected files appear in the directory. Set jobs: [] to disable all jobs with no warning.

Omitting the jobs: key entirely falls back to auto-discovery (scanning all .ts files in jobs/) with a startup warning. The security fix in 0.13 introduced the explicit allowlist; migrate by adding the jobs: list to your config.

Scheduling

Dune uses standard 5-field cron syntax:

┌───── minute (0–59)
│ ┌───── hour (0–23)
│ │ ┌───── day of month (1–31)
│ │ │ ┌───── month (1–12)
│ │ │ │ ┌───── day of week (0–6, Sunday=0)
│ │ │ │ │
* * * * *

Common examples:

Expression Meaning
0 * * * * Every hour on the hour
0 8 * * 1 Every Monday at 08:00
0 0 * * * Daily at midnight
*/15 * * * * Every 15 minutes
0 3 1 * * First of each month at 03:00

On Deno Deploy, Dune uses Deno.cron() for native scheduling. On all other environments, a 1-minute tick interval is used.

Job state

Each job's execution state is persisted to {runtimeDir}/jobs/{name}.json:

Field Description
lastRun Timestamp (ms) of the most recent execution start, or null if never run.
nextRun Best-estimate timestamp (ms) of the next scheduled run.
status "idle", "running", or "errored".
lastError Error message from the most recent failed run, or null.

State files are in runtimeDir (default .dune/admin/) — gitignored, not committed to version control.

Manual trigger

Trigger a job immediately from the admin panel or via the API:

POST /admin/api/jobs/{name}/run

Requires admin authentication, the config.update permission, and a valid CSRF token. Fires the job asynchronously and returns immediately — 200 with { "triggered": true, "name": "…" } as soon as the run starts, not once it completes. If the job name isn't registered, returns 404 with { "error": "…" }. A handler that throws after the response has already been sent is only logged to the server console ([dune/jobs] Manual run of {name} failed: …) — it does not surface in the HTTP response or change its status code. Poll GET /admin/api/jobs (or the state file) afterward to see whether it actually succeeded.

Error handling

If a job handler throws, the error is logged (including the message) and the job's status is set to "errored". Scheduling continues — the next tick will fire regardless of the previous run's outcome. There is no automatic retry.

A handler that hangs (an infinite loop, a fetch() that never resolves) is treated the same way once it hits its timeout (default 10 minutes) — logged as an error and status set to "errored", unblocking future runs of that job. Without this, a hung handler previously left status stuck at "running" forever.

Multi-process deployments

If your site runs multiple server processes (e.g. --workers 4), each process runs its own scheduler. All processes will attempt to fire the same job at the same scheduled time. Dune emits a startup warning when workers > 1 and jobs are configured.

To avoid duplicate execution in multi-process deployments, either:

  • Run jobs on a single dedicated worker using a process-level guard (e.g. DUNE_JOB_WORKER=1 env var checked in the handler), or
  • Use an external job queue (Redis, Postgres LISTEN/NOTIFY) with your handler as the consumer.