---
slug: 34-cron
title: Cron
description: Schedule background tasks in Synsema with cron_every and cron_after, manage them with cron_cancel/cron_list, and keep them alive with serve.
example_ids: [cron]
---

# Cron

A built-in background scheduler. Each job runs on its own thread, non-blocking, and truly executes its task — the counters in `cron_list()` reflect real executions.

```synsema
-- Doc example: cron scheduler. Jobs run on background threads and EXECUTE their
-- task for real — the doctest asserts the observable effect, not just registration.
intent: "doc example: cron"
require time
require file("_doctest_cron.txt")

task write_marker()
    write_file("_doctest_cron.txt", "cron ran")

print("scheduled jobs: " + text(length(cron_list())))

test "a scheduled job executes its task and the counters tell the truth"
    cron_after(0.2, write_marker)
    sleep(0.8)
    assert_eq(read_file("_doctest_cron.txt"), "cron ran")
    assert_eq(length(cron_list()), 1)
    each j in cron_list()
        assert_eq(j["name"], "write_marker")
        assert_eq(j["run_count"], 1)
        assert_eq(j["errors"], 0)
```

## Scheduling

```synsema
task sync_inventory()
    let data be http_get("https://api.warehouse.com/stock")
    share data as "inventory"

cron_every(300, sync_inventory)     -- every 5 minutes (interval: end of a run → next start)
cron_after(3600, send_reminder)     -- once, after 1 hour

-- Wall-clock: a cron expression (5 fields) or an alias
cron_every("0 9 * * *", daily_report)                          -- every day at 09:00 UTC
cron_every("30 8 * * mon-fri", standup, {"tz": "-03:00"})      -- weekdays 08:30, fixed -03:00 offset
cron_every("*/15 * * * *", sync_inventory)                     -- :00 :15 :30 :45, aligned to the clock
cron_every("@hourly", rotate_logs)                              -- @hourly @daily @weekly @monthly @yearly
```

The task must take **0 parameters** and be defined at the top level (the job runs it by name). A task with required parameters fails **at registration** with a clear error — wrap it in a zero-argument task. `cron_every` requires a positive interval; `cron_after` accepts a delay of 0 (runs right away). The task argument is the reference (`sync_inventory`) or its name as text (`"sync_inventory"`); both builtins **return the job name** (text), which is what `cron_cancel(name)` takes.

## Managing

```synsema
cron_cancel("sync_inventory")       -- stop a job
let jobs be cron_list()             -- list all jobs
print(cron_status())                -- formatted status
```

Each `cron_list()` entry carries `name`, `schedule` (`"every 300.0s"`, `"after 60.0s"` or the cron expression), `interval` (seconds, or `nothing` for expression jobs), `repeating`, `active`, `run_count` (**completed** executions), `errors` (ticks that ended in an error), `next_run` (unix timestamp of the next fire) and `tz` (`"UTC"`/`"+HH:MM"`, or `nothing` for interval jobs). `cron_status()` prints the same as text: `[active] daily_report: at '0 9 * * *' (UTC), next 2026-08-30T09:00:00Z, runs: 3, errors: 0`. Registering a job with the same name **replaces** the previous one (counters restart from zero).

## Semantics

- **Two kinds of schedule.** A **number** is an interval: a fixed delay **between the end of one execution and the start of the next** (it drifts by the run's duration — fine for "every 6 hours"). A **text** is a cron expression: `minute hour day month weekday` with `*`, ranges `a-b`, steps `*/n`, lists, month/weekday names (`jan..dec`, `sun..sat`; `0` and `7` are Sunday), or an alias (`@hourly`, `@daily`, `@weekly`, `@monthly`, `@yearly`). When both day-of-month and weekday are restricted, either one matching fires the job (the classic Vixie rule). The job fires at the **next matching minute after the previous run ends** — occurrences that fall while a run is still in progress are skipped, never queued.
- **UTC by default.** Like every `time` builtin. `{"tz": "-03:00"}` (or `"+05:30"`) shifts the expression by a **fixed offset**. IANA zones with daylight saving (`America/Sao_Paulo`) are not supported and fail with a clear error — write the offset, or schedule in UTC. A bad expression, one that never matches (`0 0 31 2 *`), an unknown option or options with a numeric interval all fail **at registration**; nothing gets scheduled.
- **No overlap.** A job never runs two ticks at once: if the task takes longer than the interval, ticks serialize.
- **Errors: the job keeps going.** A runtime error in the task increments `errors`, is logged through the server log (`[serve] [cron] job 'x' failed: …`), and the job stays scheduled for the next tick. The process never dies because of a failed tick.
- **In-memory state, no catch-up.** A restart re-registers the jobs when the top level runs again; `run_count` starts from 0 and runs missed while the process was down do not exist — for expressions too. That is deliberate: the scheduler holds no state that could lie. If you need "it ran late", the program owns that decision — keep `last_run` yourself (a file or a table) and compare it with `now()` / `next_run` on boot, then `cron_after(0, task)` if it is overdue.

## Under serve: shared state

Under `synsema serve`, jobs run with the **same shared state and the same capabilities** as your routes: db, `state_*`, memory, blackboard. Top-level jobs start only once the server is already serving, and a `cron_every` registered **from a route** works and is globally visible.

```synsema
task tick()
    state_incr("heartbeats", 1)

cron_every(60, tick)

serve on 8080
    route "GET /health"
        give state_get("heartbeats")
```

## Keeping jobs alive

Under `run`, jobs execute while the program lives and stop when it ends. Jobs share state **with each other** (one job can read what another wrote via `state_*` or `remember`). To exchange data with the rest of the program, use external effects: a file (`write_file`/`read_file`), an on-disk database, or the blackboard (`share`/`observe`). Under `serve` none of this is needed: jobs and routes already see the same shared state.

Use `synsema serve` to keep the process — and the schedule — running, even with no routes:

```sh
synsema serve scheduler.syn         -- "Serving N cron job(s). Press Ctrl+C to stop."
```

Job threads wait parked (zero CPU between ticks); each job's interpreter is built once and reused.
