cronuru
Guide

tokio-cron-scheduler: Cron Jobs in Async Rust

`tokio-cron-scheduler` is the scheduler most async Rust services reach for — it runs cron-style jobs directly inside your Tokio runtime, with optional PostgreSQL or NATS persistence so schedules survive restarts. This guide covers its 6-field cron format (the #1 gotcha), async jobs, timezones, durability, and graceful shutdown, accurate to version 0.15.

Updated

Add it to your project

tokio-cron-scheduler runs on a Tokio runtime, so you need both crates:

[dependencies]
tokio = { version = "1", features = ["full"] }
tokio-cron-scheduler = "0.15"

The latest release at the time of writing is 0.15.1, with over 4.4 million downloads — it’s the de-facto async scheduler in the Rust ecosystem.

Your first scheduled job

A scheduler is created with JobScheduler::new(), jobs are added with sched.add(), and the whole thing is kicked off with sched.start():

use tokio_cron_scheduler::{Job, JobScheduler};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let sched = JobScheduler::new().await?;

    // Runs every 5 minutes (6-field: sec min hour dom month dow)
    sched.add(Job::new_async("0 */5 * * * *", |_uuid, _lock| {
        Box::pin(async move {
            refresh_cache().await;
        })
    })?).await?;

    sched.start().await?;

    // Keep the process alive until Ctrl-C
    tokio::signal::ctrl_c().await?;
    Ok(())
}

Job::new_async takes the cron string and a closure that returns a boxed future. The closure receives the job’s Uuid and a lock handle you can use to query or stop the job. Use Job::new (without the _async suffix) for synchronous closures.

The 6-field cron format

This is the part that trips everyone up. Since 0.15, tokio-cron-scheduler parses expressions with croner, which expects a seconds-first, 6-field layout:

┌───────────── second (0–59)
│ ┌─────────── minute (0–59)
│ │ ┌───────── hour (0–23)
│ │ │ ┌─────── day of month (1–31)
│ │ │ │ ┌───── month (1–12 or JAN–DEC)
│ │ │ │ │ ┌─── day of week (0–6 or SUN–SAT)
│ │ │ │ │ │
0 */5 * * * *

That extra leading field is the difference from a Unix crontab. Porting a standard 5-field expression means adding a seconds value at the front:

ScheduleUnix (5-field)tokio-cron-scheduler (6-field)
Every 5 minutes*/5 * * * *0 */5 * * * *
Every hour0 * * * *0 0 * * * *
9 AM daily0 9 * * *0 0 9 * * *
Weekdays at 9 AM0 9 * * 1-50 0 9 * * MON-FRI
Every 7 seconds(not expressible)1/7 * * * * *

Because the seconds field exists, tokio-cron-scheduler can do sub-minute cron schedules the Unix format simply can’t. Run any expression through the cron parser first if you’re unsure when it fires.

Timezone-aware jobs

By default, cron expressions are evaluated in the system’s local time. To pin a job to a specific timezone, use the timezone-aware constructor with a chrono-tz zone:

chrono-tz = "0.10"
use tokio_cron_scheduler::Job;

// 9 AM New York time, weekdays — regardless of server timezone
sched.add(Job::new_async_tz(
    "0 0 9 * * MON-FRI",
    chrono_tz::America::New_York,
    |_uuid, _lock| Box::pin(async move {
        send_morning_digest().await;
    }),
)?).await?;

Pinning the timezone is the safest default for anything user-facing — it keeps “9 AM” meaning 9 AM for your users even when the server runs in UTC, and it sidesteps daylight-saving surprises around the 2–3 AM transition window.

Intervals and one-shot jobs

Not everything needs cron syntax. tokio-cron-scheduler also schedules fixed intervals and single future runs:

use std::time::Duration;
use tokio_cron_scheduler::Job;

// Every 60 seconds, no cron expression
sched.add(Job::new_repeated_async(
    Duration::from_secs(60),
    |_uuid, _lock| Box::pin(async move {
        heartbeat().await;
    }),
)?).await?;

// Once, 10 seconds from now
sched.add(Job::new_one_shot_async(
    Duration::from_secs(10),
    |_uuid, _lock| Box::pin(async move {
        warm_up().await;
    }),
)?).await?;

For genuinely high-frequency loops (sub-second), skip the scheduler entirely and use tokio::time::interval directly — it’s lighter and more idiomatic than registering a cron job.

Surviving restarts

By default every schedule lives in memory, so a restart wipes them and they’re rebuilt only when your code re-adds them. That’s fine when your jobs are defined in code and re-registered on boot. It is not fine if jobs are created dynamically at runtime (e.g. user-defined schedules) — those vanish on restart.

For dynamic schedules, enable a storage backend:

tokio-cron-scheduler = { version = "0.15", features = ["postgres_storage"] }
# or:  features = ["nats_storage"]

With a backend enabled, schedule metadata and notifications are persisted (to PostgreSQL or NATS) and restored on startup. Note this persists the schedule definitions, not a durable record of every individual run — if you need guaranteed, retried execution of each job, reach for apalis instead.

Graceful shutdown

Always shut the scheduler down explicitly so in-flight jobs can finish and any storage backend flushes cleanly. Wait on a termination signal, then call shutdown():

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let sched = JobScheduler::new().await?;

    // ... add jobs ...

    sched.start().await?;

    // Block until SIGINT (Ctrl-C), then shut down gracefully
    tokio::signal::ctrl_c().await?;
    sched.shutdown().await?;

    Ok(())
}

In a web service, run the scheduler alongside your server with tokio::select! or tokio::join! and call shutdown() from the same termination path that stops the HTTP listener.

Production pitfalls

  • The 6-field format. Pasting a Unix 5-field expression silently parses wrong (or errors). Always add the seconds field — see the table above.
  • Overlapping runs. If a job can run longer than its interval, a new invocation may start before the previous finishes. Guard shared state, or track an in-flight flag and skip if still busy.
  • Blocking the runtime. Inside an async job, never call blocking I/O or CPU-heavy work directly — it stalls the Tokio worker. Use tokio::task::spawn_blocking for blocking work.
  • In-memory schedules. Without a storage feature, dynamically-added jobs don’t survive restarts. Re-register code-defined jobs on boot, or enable postgres_storage/nats_storage.
  • Local-time drift. Cron expressions default to system local time. Pin a timezone with Job::new_async_tz for anything that must fire at a specific wall-clock time.

For the full landscape of Rust scheduling crates and when to pick each, see the Rust cron scheduling guide. For durable, retry-backed jobs, see apalis.

Frequently asked questions

What is tokio-cron-scheduler?
tokio-cron-scheduler is a Rust crate that schedules and runs cron-style jobs inside a Tokio async runtime. It supports cron expressions, fixed intervals, and one-shot jobs, with optional PostgreSQL or NATS persistence. It's the most-downloaded async scheduler for Rust and the usual choice for axum, actix-web, and other Tokio-based services.
What cron format does tokio-cron-scheduler use?
As of version 0.15 it uses the croner parser with a 6-field, seconds-first format: second, minute, hour, day-of-month, month, day-of-week. A standard Unix 5-field expression needs a leading seconds field — `*/5 * * * *` becomes `0 */5 * * * *`. This is the single most common mistake when porting a crontab expression to Rust.
Does tokio-cron-scheduler persist jobs across restarts?
Not by default — schedules live in memory and are lost on restart. Enable the `postgres_storage` or `nats_storage` feature flag to persist schedule metadata to PostgreSQL or NATS, which restores jobs after a restart. For durable per-run job processing with retries, apalis is the better fit.
How do I gracefully shut down tokio-cron-scheduler?
`JobScheduler` exposes an async `shutdown()` method. Wait on a signal such as `tokio::signal::ctrl_c()`, then call `sched.shutdown().await` before the process exits so in-flight jobs can finish and storage is flushed.
tokio-cron-scheduler or apalis — which should I use?
Use tokio-cron-scheduler when you want lightweight in-process scheduling and you're already on Tokio. Use apalis when you need durable, restart-resilient jobs backed by Redis or a SQL database, with retries and middleware. tokio-cron-scheduler schedules work; apalis is a full background-job framework that can also run on a cron schedule.