cronuru
Guide

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.

Schedulejob_scheduler expression
Every 10 seconds1/10 * * * * *
Every 5 minutes0 */5 * * * *
6 am on weekends0 0 6 * * Sun,Sat
Once/hour, days 5–100 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 caseMove to
Simple in-process periodic taskstokio-cron-scheduler
Jobs that must not be dropped (billing, reports)apalis
You only need to parse expressions, not run themthe cron crate
Plain intervals, no cron syntax wantedclokwerk

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?
No. The crate's own README states it is no longer maintained, and its last release predates 2021. The author recommends looking at other options and has invited someone to take the project over. For new code, use tokio-cron-scheduler or apalis instead; only keep job_scheduler if you're maintaining an existing codebase and aren't ready to migrate yet.
What cron format did job_scheduler use?
job_scheduler parsed schedules with the Rust `cron` crate, so it used the 7-field, seconds-first format: second, minute, hour, day-of-month, month, day-of-week, year (year optional). Times were interpreted as UTC, not local time. For example `1/10 * * * * *` fires every 10 seconds, and `0 0 6 * * Sun,Sat` fires at 6 am on weekends.
How do I migrate from job_scheduler to tokio-cron-scheduler?
The cron expressions carry over almost unchanged — both use the `cron` crate's seconds-first format. Replace `JobScheduler::new()` + the manual `tick()`/`sleep` loop with tokio-cron-scheduler's async `JobScheduler::new().await`, add jobs with `Job::new_async(expr, |_uuid, _l| Box::pin(async { ... }))`, and call `sched.start().await` — the scheduler owns the timing loop, so you delete your own.
Why replace job_scheduler at all if it still works?
It still runs, but it's unmaintained (no bug fixes or security updates), it blocks a thread on a manual tick loop, and it has no async support, persistence, or retries. Moving to tokio-cron-scheduler gets you an async-native, maintained scheduler; moving to apalis adds durability and retries. Both keep your existing cron expressions, so the migration is mostly mechanical.