cronuru
Guide

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

CrateWhat it doesBest for
cronCron expression parsing, next-run computationCustom schedulers, validation, displaying next-runs
tokio-cron-schedulerAsync scheduler for Tokio runtimeMost async Rust services
apalisRedis-backed durable job queueRestart-resilient distributed scheduling
clokwerkInterval-DSL scheduler (no cron strings)Simple “every N / daily at X” schedules
job_schedulerSync in-process cron schedulerUnmaintained — legacy code only; migrate to tokio-cron-scheduler
cron_clockCalendar-based scheduling with timezone supportSpecialized 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 cron crate’s 7-field
  • Async by default — jobs return futures, runs cooperatively in the Tokio runtime
  • Timezone-aware via Job::new_async_tz with a chrono_tz timezone
  • 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:

Intervaltokio-cron-schedulerPattern reference
Every 30 seconds*/30 * * * * *every 30 seconds
Every 5 minutes0 */5 * * * *every 5 minutes
Every 15 minutes0 */15 * * * *every 15 minutes
Hourly (top of hour)0 0 * * * *every hour
Every 6 hours0 0 */6 * * *every 6 hours
Daily at 9 AM0 0 9 * * *daily at 9 AM
Weekdays at 9 AM0 0 9 * * MON-FRIweekdays at 9 AM
Midnight daily0 0 0 * * *daily at midnight

Two dialect reminders when copying these around:

  • apalis-cron adds a trailing year field (7-field cron crate), so “every 5 minutes” is 0 */5 * * * * * there — one more * than tokio-cron-scheduler.
  • The cron crate (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?cron crate
  • 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?
For parsing cron expressions: the `cron` crate. For running jobs in an async Tokio runtime: `tokio-cron-scheduler`. For Redis-backed durable jobs: `apalis`. For simple in-process scheduling without async overhead: `clokwerk`. Most modern Rust services pick `tokio-cron-scheduler` since they're already on Tokio.
What cron syntax does the cron crate accept?
The Rust cron crate uses a 7-field syntax: seconds, minutes, hours, day-of-month, month, day-of-week, year. This is closer to Quartz than to Unix. Standard 5-field Unix expressions need a leading `0` for seconds and a trailing `*` for year: `*/5 * * * *` becomes `0 */5 * * * * *`. Always double-check parsed expressions against the parser's expectations.
Does tokio-cron-scheduler survive restarts?
Not by default — schedules are in-memory. With the `postgres-storage` or `nats-storage` feature flag, you can persist schedules to PostgreSQL or NATS, which restores them after restarts. For simpler durability needs, apalis offers Redis-backed jobs out of the box.
How do I shut down a Rust scheduler gracefully?
tokio-cron-scheduler returns a `JobScheduler` that has a `shutdown()` method. Call it from your shutdown signal handler before exiting. Combine with a `tokio::signal::ctrl_c()` handler for SIGINT/SIGTERM. For apalis, the worker's `Monitor` has built-in graceful shutdown support.
Can Rust schedulers run sub-second?
Most crates support sub-second only via `OnInterval`-style triggers, not cron expressions (cron's minimum is 1 second in 7-field crates, 1 minute in 5-field). For Tokio-based services, just use `tokio::time::interval(Duration::from_millis(100))` directly for high-frequency loops — it's more idiomatic than reaching for a scheduler.