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:
| Schedule | Unix (5-field) | tokio-cron-scheduler (6-field) |
|---|---|---|
| Every 5 minutes | */5 * * * * | 0 */5 * * * * |
| Every hour | 0 * * * * | 0 0 * * * * |
| 9 AM daily | 0 9 * * * | 0 0 9 * * * |
| Weekdays at 9 AM | 0 9 * * 1-5 | 0 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_blockingfor 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_tzfor 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?
What cron format does tokio-cron-scheduler use?
Does tokio-cron-scheduler persist jobs across restarts?
How do I gracefully shut down tokio-cron-scheduler?
tokio-cron-scheduler or apalis — which should I use?
Related
Every 5 Minutes
`0 */5 * * * *` in tokio-cron-scheduler's 6-field syntax.
ToolCron Parser
Check a cron expression's next runs before wiring it into a job.
GuideRust Cron Scheduling
The full comparison of Rust scheduling crates — cron, apalis, clokwerk.
Guideapalis: Durable Jobs in Rust
When you need restart-resilient, storage-backed scheduled jobs.
GuideGo Cron Scheduling
The Go equivalent — robfig/cron, gocron — for comparison.