Rust Cron Scheduling — A Complete Guide
Rust's scheduling crates are split by execution model: `cron` parses expressions, `tokio-cron-scheduler` runs jobs in an async runtime, `apalis` adds Redis-backed durability, and `clokwerk` covers simpler in-process needs. This guide compares all four with current code samples, maps common intervals to Rust, and shows how to run a scheduler alongside an Axum or Actix web server.
Updated
The crate landscape
| Crate | What it does | Best for |
|---|---|---|
| cron | Cron expression parsing, next-run computation | Custom schedulers, validation, displaying next-runs |
| tokio-cron-scheduler | Async scheduler for Tokio runtime | Most async Rust services |
| apalis | Redis-backed durable job queue | Restart-resilient distributed scheduling |
| clokwerk | Interval-DSL scheduler (no cron strings) | Simple “every N / daily at X” schedules |
| job_scheduler | Sync in-process cron scheduler | Unmaintained — legacy code only; migrate to tokio-cron-scheduler |
| cron_clock | Calendar-based scheduling with timezone support | Specialized timezone scheduling |
For most modern Rust web services (axum, actix-web, rocket, all async): tokio-cron-scheduler. For background batch processing with Redis/PostgreSQL: apalis. Everything else is niche.
cron
Pure cron expression parsing and next-run computation. Doesn’t run anything.
[dependencies]
cron = "0.17"
chrono = "0.4"
use cron::Schedule;
use std::str::FromStr;
use chrono::Utc;
// 7-field syntax: sec min hour day month weekday year
let expr = "0 */5 * * * * *"; // every 5 minutes
let schedule = Schedule::from_str(expr).unwrap();
// Next 10 fire times
for next in schedule.upcoming(Utc).take(10) {
println!("{}", next);
}
// Validate a user-submitted expression
if Schedule::from_str(user_input).is_ok() {
// safe
}
The Rust cron crate uses 7-field syntax (seconds at the front, year at the end), not standard 5-field Unix. This is the most common gotcha — porting a Unix expression like */5 * * * * requires adding 0 for seconds and * for year: 0 */5 * * * * *.
Use the cron crate when you need to:
- Validate cron expressions in a config file or API
- Display next-run times in a UI
- Build your own scheduler on top of the cron parser
- Compute “how often” a schedule fires (count
.upcoming()over a date range)
tokio-cron-scheduler
The async scheduler most Rust web services reach for.
[dependencies]
tokio = { version = "1", features = ["full"] }
tokio-cron-scheduler = "0.15"
use tokio_cron_scheduler::{Job, JobScheduler};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let sched = JobScheduler::new().await?;
// Cron-style — 6-field with seconds
sched.add(Job::new_async("0 */5 * * * *", |_uuid, _lock| {
Box::pin(async move {
refresh_cache().await;
})
})?).await?;
// Or with 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?;
// Or interval-based (no cron syntax)
sched.add(Job::new_repeated_async(
std::time::Duration::from_secs(60),
|_uuid, _lock| Box::pin(async move {
heartbeat().await;
}),
)?).await?;
sched.start().await?;
// Wait forever (or until shutdown signal)
tokio::signal::ctrl_c().await?;
sched.shutdown().await?;
Ok(())
}
Notes:
- 6-field syntax (with seconds at the front), unlike the
croncrate’s 7-field - Async by default — jobs return futures, runs cooperatively in the Tokio runtime
- Timezone-aware via
Job::new_async_tzwith achrono_tztimezone - Persistent storage behind feature flags:
postgres-storage,nats-storage. Default is in-memory. - Graceful shutdown via
JobScheduler::shutdown()
For axum/actix-web/rocket apps that need scheduled background work, drop this into your main and you’re done.
apalis
Redis-backed (or PostgreSQL-backed) job queue with cron-style scheduling.
[dependencies]
apalis = "0.7"
apalis-cron = "0.7"
tokio = { version = "1", features = ["full"] }
chrono = "0.4"
use apalis::prelude::*;
use apalis_cron::{CronStream, Schedule};
use chrono::Local;
use std::str::FromStr;
#[derive(Default, Debug, Clone)]
struct Reminder;
async fn send_reminder(_job: Reminder, _ctx: CronContext<Local>) {
println!("Sending reminders...");
}
#[tokio::main]
async fn main() {
// 7-field cron-crate syntax (trailing year): every 5 minutes
let schedule = Schedule::from_str("0 */5 * * * * *").unwrap();
let worker = WorkerBuilder::new("reminder-worker")
.backend(CronStream::new(schedule))
.build_fn(send_reminder);
worker.run().await;
}
The stable line is 0.7 (a 1.0 is in the release-candidate stage — pin your version). This bare CronStream fires in memory; durability comes from routing jobs through apalis-redis or apalis-sql. See the apalis guide for storage, retries, and middleware.
What apalis adds beyond tokio-cron-scheduler:
- Persistent storage (Redis, PostgreSQL, SQLite) — survives restarts
- Distributed by default — multiple workers across processes coordinate via the storage backend
- Retry policies, dead-letter handling
- Job middleware (logging, tracing, metrics)
Trade-off: more infrastructure (need Redis or Postgres) and more setup code. For services that already use Redis and need durable scheduled jobs, the trade is usually worth it.
clokwerk
A simpler scheduler with an English-like DSL — you write intervals (every(5.minutes())) instead of cron strings. It has a sync Scheduler and, since 0.4, an AsyncScheduler for Tokio/async-std. Full walkthrough in the clokwerk guide.
[dependencies]
clokwerk = "0.4"
use clokwerk::{Scheduler, TimeUnits};
use std::time::Duration;
let mut scheduler = Scheduler::new();
scheduler.every(5.minutes()).run(|| {
println!("Every 5 minutes");
});
scheduler.every(1.day()).at("09:00").run(|| {
println!("Daily at 9 AM");
});
scheduler.every(1.day()).at("17:00")
.and_every(1.day()).at("17:30")
.run(|| println!("5 PM and 5:30 PM"));
// Run pending tasks in a loop
loop {
scheduler.run_pending();
std::thread::sleep(Duration::from_secs(10));
}
Use clokwerk when:
- You want readable intervals over cron syntax
- It’s a CLI tool, daemon, or quick script
- Your schedules are plain “every N minutes / daily at X” rules, not full cron grammar
For anything needing real cron expressions or an actively maintained crate, tokio-cron-scheduler is the right call. See the clokwerk guide for the async scheduler, timezones, and the full DSL.
Common intervals in Rust
Most scheduled work is a handful of familiar cadences. Here they are in tokio-cron-scheduler’s 6-field syntax (seconds first), each linked to a full breakdown of the expression:
| Interval | tokio-cron-scheduler | Pattern reference |
|---|---|---|
| Every 30 seconds | */30 * * * * * | every 30 seconds |
| Every 5 minutes | 0 */5 * * * * | every 5 minutes |
| Every 15 minutes | 0 */15 * * * * | every 15 minutes |
| Hourly (top of hour) | 0 0 * * * * | every hour |
| Every 6 hours | 0 0 */6 * * * | every 6 hours |
| Daily at 9 AM | 0 0 9 * * * | daily at 9 AM |
| Weekdays at 9 AM | 0 0 9 * * MON-FRI | weekdays at 9 AM |
| Midnight daily | 0 0 0 * * * | daily at midnight |
Two dialect reminders when copying these around:
- apalis-cron adds a trailing year field (7-field
croncrate), so “every 5 minutes” is0 */5 * * * * *there — one more*than tokio-cron-scheduler. - The
croncrate (for parsing/next-runs) is also 7-field. Only tokio-cron-scheduler is 6-field.
Unsure what an expression fires? Run it through the cron parser first.
Running alongside a web server
The most common real-world setup: a scheduler running next to an HTTP server, on the same Tokio runtime. The trick is to start the scheduler, then hand control to the server, and shut the scheduler down once the server stops. With Axum:
use axum::{routing::get, Router};
use tokio_cron_scheduler::{Job, JobScheduler};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// 1. Start the scheduler on the current runtime.
let sched = JobScheduler::new().await?;
sched.add(Job::new_async("0 */5 * * * *", |_uuid, _lock| {
Box::pin(async move { refresh_cache().await; })
})?).await?;
sched.start().await?;
// 2. Serve HTTP on the same runtime, with graceful shutdown.
let app = Router::new().route("/", get(|| async { "ok" }));
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
axum::serve(listener, app)
.with_graceful_shutdown(async { tokio::signal::ctrl_c().await.ok(); })
.await?;
// 3. Server has stopped — drain in-flight jobs.
sched.shutdown().await?;
Ok(())
}
For actix-web, start the scheduler before HttpServer::run() — actix runs on Tokio, so the same scheduler works:
#[actix_web::main]
async fn main() -> std::io::Result<()> {
let sched = JobScheduler::new().await.unwrap();
// sched.add(...).await.unwrap();
sched.start().await.unwrap();
HttpServer::new(|| App::new().route("/", web::get().to(|| async { "ok" })))
.bind(("0.0.0.0", 8080))?
.run()
.await
}
Two things to get right:
- One runtime, not two. The scheduler and the server must share the same Tokio runtime — don’t wrap the scheduler in its own
Runtime::new().#[tokio::main](Axum) and#[actix_web::main](actix) already give you one. - Don’t block the runtime inside a job. A scheduled closure that does blocking I/O or heavy CPU work stalls the same workers serving HTTP requests. Offload with
tokio::task::spawn_blocking.
If a scheduled job needs to reliably do work (not just fire), have it enqueue an apalis job instead of doing the work inline — see the compose pattern in the tokio-cron-scheduler vs apalis comparison.
For the full walkthrough — sharing your app state (DB pools) with jobs, background tasks from a request handler, and graceful shutdown in both frameworks — see Scheduled tasks in Axum & Actix.
Choosing the right one
- Need to parse cron expressions, not run jobs? →
croncrate - Async Rust service (axum/actix/rocket/etc.)? →
tokio-cron-scheduler - Need persistence + distributed workers + retry? →
apalis(Redis or PostgreSQL) - Sync CLI tool or daemon? →
clokwerk - Need SLA-grade reliability? → Move scheduling out of Rust. Use Kubernetes CronJob or AWS EventBridge Scheduler to invoke a Rust HTTP endpoint or run a Rust binary on a schedule.
For most production Rust services in 2026: tokio-cron-scheduler with the postgres-storage feature when you need restart resilience. Move to apalis if you need full distributed job-queue semantics — see the tokio-cron-scheduler vs apalis head-to-head if you’re deciding between the two. Decouple to Kubernetes CronJob if you need true scheduler-grade SLA.
Frequently asked questions
Which Rust scheduling crate should I use?
What cron syntax does the cron crate accept?
Does tokio-cron-scheduler survive restarts?
How do I shut down a Rust scheduler gracefully?
Can Rust schedulers run sub-second?
Related
Every 5 Minutes
`*/5 * * * *` — common Rust scheduled-job interval.
ToolCron Parser
Verify cron expressions before adding them to a scheduler.
GuideThe cron Crate
Rust's most-downloaded cron parser — the 7-field format and computing run times.
GuideScheduled Tasks in Axum & Actix
Run cron jobs alongside your web server — shared state, shutdown, durable work.
Guidetokio-cron-scheduler
Deep dive on the async scheduler most Rust services reach for.
Guideapalis: Durable Jobs in Rust
Storage-backed, retry-resilient scheduled jobs in Rust.
Guideclokwerk
Interval-DSL scheduling in Rust when you don't want cron syntax.
Guidejob_scheduler (Legacy)
The unmaintained cron scheduler — how it worked and how to migrate off it.
GuideGo Cron Scheduling
The Go equivalents — robfig/cron, go-co-op/gocron.
GuideNode.js Cron Jobs
The Node equivalents for comparison.
GuideKubernetes CronJob
Run scheduled Rust binaries as K8s CronJobs for production reliability.