Building a Cron Job System in Rust with Tokio and Cronexpr
If you're already using Rust and want to schedule background tasks—whether it's polling an API, rotating logs, or syncing files—you don’t need an external cron daemon or a job queue. In this post, we’ll build a fully async, embeddable cron job system using tokio and cronexpr, perfect for services that need internal scheduling with precision and zero dependencies. Concept Breakdown We'll use cronexpr to parse traditional cron expressions like "*/5 * * * * *" (every 5 seconds), and tokio to handle the async execution loop. Jobs are just async closures you register with the scheduler. Full Working Code use std::{sync::Arc, time::Duration}; use tokio::{time::sleep, sync::Mutex}; use cronexpr::CronExpr; use chrono::{Utc, DateTime}; type Job = Arc JobFuture + Send + Sync>; type JobFuture = std::pin::Pin; struct Scheduler { jobs: Vec, } impl Scheduler { fn new() -> Self { Self { jobs: vec![] } } fn add_job(&mut self, cron_str: &str, job_fn: F) where F: Fn() -> JobFuture + Send + Sync + 'static, { let expr = CronExpr::parse(cron_str).expect("Invalid cron expression"); self.jobs.push((expr, Arc::new(job_fn))); } async fn run(&self) { let jobs = Arc::new(self.jobs.clone()); loop { let now = Utc::now(); for (expr, job) in jobs.iter() { if let Some(next_time) = expr.next_after(&now) { if (next_time - now).num_milliseconds() < 1000 { let job = job.clone(); tokio::spawn(async move { (job)().await; }); } } } sleep(Duration::from_secs(1)).await; } } } [tokio::main] async fn main() { let mut scheduler = Scheduler::new(); scheduler.add_job("*/2 * * * * *", || { Box::pin(async { println!("Job 1 ran at {:?}", Utc::now()); }) }); scheduler.add_job("*/5 * * * * *", || { Box::pin(async { println!("Job 2 ran at {:?}", Utc::now()); }) }); scheduler.run().await; } Explanation of How It Works Each job is tied to a cron expression, parsed via cronexpr. The scheduler loops every second, and for each job, it checks if the next scheduled time is within the next second—if so, it runs the job using tokio::spawn to avoid blocking the loop. All jobs are defined as async closures boxed into a future so they can execute independently. Pros & Cons ✅ Pros No system-level cron dependency—fully in-process. Lightweight and async-native using tokio. Simple to embed in services, CLIs, or microservices. Control over job logic, logging, and timing behavior. ⚠️ Cons Jobs don't persist across restarts—no durability layer. Time drift possible over long uptime without correction logic. No built-in retry, failure handling, or metrics (though you could add it). Requires app to run continuously—no standalone daemon behavior yet. Wrap-Up For many use cases, this embedded approach is more than enough—fast, predictable, and easy to maintain. You skip the overhead of managing external job runners and get something that plays nicely with Rust’s async model. Extend it with job persistence, tracing, or even a Web UI if you're feeling fancy. If this was useful, you can Buy Me a Coffee ☕
If you're already using Rust and want to schedule background tasks—whether it's polling an API, rotating logs, or syncing files—you don’t need an external cron daemon or a job queue. In this post, we’ll build a fully async, embeddable cron job system using tokio
and cronexpr
, perfect for services that need internal scheduling with precision and zero dependencies.
Concept Breakdown
We'll use cronexpr
to parse traditional cron expressions like "*/5 * * * * *"
(every 5 seconds), and tokio
to handle the async execution loop. Jobs are just async closures you register with the scheduler.
Full Working Code
use std::{sync::Arc, time::Duration};
use tokio::{time::sleep, sync::Mutex};
use cronexpr::CronExpr;
use chrono::{Utc, DateTime};
type Job = Arc JobFuture + Send + Sync>;
type JobFuture = std::pin::Pin + Send>>;
struct Scheduler {
jobs: Vec<(CronExpr, Job)>,
}
impl Scheduler {
fn new() -> Self {
Self { jobs: vec![] }
}
fn add_job(&mut self, cron_str: &str, job_fn: F)
where
F: Fn() -> JobFuture + Send + Sync + 'static,
{
let expr = CronExpr::parse(cron_str).expect("Invalid cron expression");
self.jobs.push((expr, Arc::new(job_fn)));
}
async fn run(&self) {
let jobs = Arc::new(self.jobs.clone());
loop {
let now = Utc::now();
for (expr, job) in jobs.iter() {
if let Some(next_time) = expr.next_after(&now) {
if (next_time - now).num_milliseconds() < 1000 {
let job = job.clone();
tokio::spawn(async move {
(job)().await;
});
}
}
}
sleep(Duration::from_secs(1)).await;
}
}
}
[tokio::main]
async fn main() {
let mut scheduler = Scheduler::new();
scheduler.add_job("*/2 * * * * *", || {
Box::pin(async {
println!("Job 1 ran at {:?}", Utc::now());
})
});
scheduler.add_job("*/5 * * * * *", || {
Box::pin(async {
println!("Job 2 ran at {:?}", Utc::now());
})
});
scheduler.run().await;
}
Explanation of How It Works
Each job is tied to a cron expression, parsed via cronexpr
. The scheduler loops every second, and for each job, it checks if the next scheduled time is within the next second—if so, it runs the job using tokio::spawn
to avoid blocking the loop. All jobs are defined as async closures boxed into a future so they can execute independently.
Pros & Cons
✅ Pros
- No system-level cron dependency—fully in-process.
- Lightweight and async-native using
tokio
. - Simple to embed in services, CLIs, or microservices.
- Control over job logic, logging, and timing behavior.
⚠️ Cons
- Jobs don't persist across restarts—no durability layer.
- Time drift possible over long uptime without correction logic.
- No built-in retry, failure handling, or metrics (though you could add it).
- Requires app to run continuously—no standalone daemon behavior yet.
Wrap-Up
For many use cases, this embedded approach is more than enough—fast, predictable, and easy to maintain. You skip the overhead of managing external job runners and get something that plays nicely with Rust’s async model. Extend it with job persistence, tracing, or even a Web UI if you're feeling fancy.
If this was useful, you can Buy Me a Coffee ☕