job_scheduler (Rust): Legacy Guide & Migration Path
`job_scheduler` was one of the first cron-like schedulers in Rust — a small crate that ran closures on cron expressions via a manual `tick()` loop. Its author has since marked it **no longer maintained**, and its last release predates 2021. This guide explains how it worked (for anyone maintaining code that still uses it) and gives a side-by-side migration to the crates that replaced it: tokio-cron-scheduler and apalis.
Updated
Status: unmaintained
Start here, because it changes everything below: job_scheduler is no longer maintained. The crate’s README says so directly — the author’s original project stopped using it, updates stopped, and the last release predates 2021. They’ve explicitly invited someone else to take it over.
That doesn’t mean existing code breaks — the crate still compiles and runs. But for anything new, treat this guide as a reference for legacy code and a migration map, not a recommendation. If you’re starting fresh, jump straight to tokio-cron-scheduler or apalis and skip the rest.
How job_scheduler worked
The model was deliberately minimal: create a JobScheduler, add Jobs built from a cron expression and a closure, then call tick() repeatedly in your own loop. There was no async and no background thread — you owned the timing:
use job_scheduler::{JobScheduler, Job};
use std::time::Duration;
fn main() {
let mut sched = JobScheduler::new();
sched.add(Job::new("1/10 * * * * *".parse().unwrap(), || {
println!("runs every 10 seconds");
}));
loop {
sched.tick();
std::thread::sleep(Duration::from_millis(500));
}
}
Each tick() checks every job’s schedule against the current time and runs any that are due. The sleep between ticks sets your resolution and, critically, blocks the thread — this loop does nothing but schedule, so scheduled work typically lived on its own thread.
The cron format it used
job_scheduler parsed expressions with the cron crate, which means the 7-field, seconds-first format — second minute hour day-of-month month day-of-week year, with the year optional. Times were always UTC, never local.
| Schedule | job_scheduler expression |
|---|---|
| Every 10 seconds | 1/10 * * * * * |
| Every 5 minutes | 0 */5 * * * * |
| 6 am on weekends | 0 0 6 * * Sun,Sat |
| Once/hour, days 5–10 | 0 0 * 5-10 * * |
Because it shares the cron crate with today’s schedulers, these expressions port to tokio-cron-scheduler and apalis nearly verbatim — the migration below barely touches them. If you’re unsure what a legacy expression actually fires, run it through the cron parser first.
Missed runs and limits
One job_scheduler quirk worth knowing when you maintain old code: because firing depended on tick() being called, a job could be missed if ticks were too far apart or the thread stalled. The crate had a limit_missed_runs setting to cap how many overdue firings it would replay after a delay, so a process that was paused didn’t suddenly run a job dozens of times on resume.
This is a symptom of the manual-tick model. Modern schedulers own their own timing loop and don’t depend on you calling tick(), which removes this whole class of bug — another reason the migration below is worth doing rather than nursing the old loop.
Migrating off it
The expressions stay; the plumbing changes. Here’s the same “every 10 seconds” job moved to tokio-cron-scheduler, which owns the timing loop so you delete your tick()/sleep code entirely:
// Cargo.toml
// 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?;
// Same seconds-first expression as job_scheduler:
sched.add(Job::new_async("1/10 * * * * *", |_uuid, _lock| {
Box::pin(async {
println!("runs every 10 seconds");
})
})?).await?;
sched.start().await?;
// Keep the process alive; the scheduler runs in the background.
tokio::signal::ctrl_c().await?;
Ok(())
}
What changed, mechanically:
JobScheduler::new()→JobScheduler::new().await— it’s async now.Job::new(expr.parse()?, closure)→Job::new_async(expr, |uuid, lock| Box::pin(async { … }))— the expression is passed as a string and the closure returns a pinned future.- Your
loop { sched.tick(); sleep }→sched.start().await— the scheduler runs itself; you just keep the process alive.
If the work must survive restarts or needs retries, migrate to apalis instead — same cron expressions, plus Redis/SQL-backed durability. See that guide for the worker setup.
Which crate to move to
| Your job_scheduler use case | Move to |
|---|---|
| Simple in-process periodic tasks | tokio-cron-scheduler |
| Jobs that must not be dropped (billing, reports) | apalis |
| You only need to parse expressions, not run them | the cron crate |
| Plain intervals, no cron syntax wanted | clokwerk |
For most former job_scheduler users, tokio-cron-scheduler is the direct replacement — async-native, actively maintained, same expression format, and it retires the manual tick loop. For the full comparison of current Rust scheduling crates, see the Rust cron scheduling guide.
Frequently asked questions
Is the Rust job_scheduler crate still maintained?
What cron format did job_scheduler use?
How do I migrate from job_scheduler to tokio-cron-scheduler?
Why replace job_scheduler at all if it still works?
Related
Every 10 Seconds
`1/10 * * * * *` — the seconds-first form job_scheduler used.
ToolCron Parser
Check what an existing job_scheduler expression fires before you port it.
Guidetokio-cron-scheduler
The actively-maintained async scheduler most job_scheduler users move to.
Guideapalis: Durable Jobs in Rust
Move here instead when the jobs need durability, retries, and storage.
GuideRust Cron Scheduling
The full landscape of current Rust scheduling crates and how to choose.