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=1env var checked in the handler), or - Use an external job queue (Redis, Postgres
LISTEN/NOTIFY) with your handler as the consumer.