cronuru
Guide

tokio-cron-scheduler vs apalis

These two crates get compared constantly, but they're not the same kind of tool. `tokio-cron-scheduler` is an in-process async scheduler — it runs closures on a cron schedule inside your Tokio runtime. `apalis` is a background-job framework, and `apalis-cron` is just one way to feed it work. The deciding question isn't which is "better" — it's whether a scheduled run being lost on a crash is acceptable. This guide compares them on category, cron format, durability, retries, and popularity, and shows how to use them together.

Updated

The short answer

  • Reach for tokio-cron-scheduler when you want to run a function on a schedule inside your async service and it’s fine to occasionally lose a run on restart. Cache refreshes, heartbeats, digest emails, periodic syncs.
  • Reach for apalis when each scheduled run is a durable unit of work — it must survive crashes, retry on failure, and maybe spread across workers. Billing, payouts, report generation, anything you can’t silently drop.

Everything else below is the reasoning behind that split.

Different tools, not rivals

The comparison is a little unfair because they sit in different categories:

  • tokio-cron-scheduler is a scheduler. Its whole job is “call this closure when the clock matches.” It holds schedules in memory, runs them on your Tokio runtime, and offers cron, fixed-interval, and one-shot jobs.
  • apalis is a background-job framework — the same family as Sidekiq (Ruby) or Celery (Python). Jobs come from a backend and are consumed by workers, with Tower middleware for retries, timeouts, concurrency, and tracing. apalis-cron is simply one backend that emits a job every time a cron schedule fires.

So the honest framing isn’t “which scheduler is better.” It’s: do you need scheduling, or do you need durable job processing that happens to be time-triggered? That single question decides it, and everything else is detail.

Cron format differences

If you take one practical thing from this page, make it this — the two crates parse different cron dialects, and pasting one into the other silently misfires:

tokio-cron-schedulerapalis-cron
Parsercronercron crate
Fields6 (seconds-first, no year)7 (seconds-first + trailing year)
Layoutsec min hour dom month dowsec min hour dom month dow year
MacrosNoYes (@daily, @hourly, …)
Scheduletokio-cron-schedulerapalis-cron
Every 5 minutes0 */5 * * * *0 */5 * * * * *
Every hour0 0 * * * *0 0 * * * * * (or @hourly)
9 AM weekdays0 0 9 * * MON-FRI0 0 9 * * Mon-Fri *

Both support a seconds field, so both can do sub-minute schedules the Unix format can’t. The gotcha is purely the field count — apalis’s trailing year. Run either through the cron parser if you’re unsure.

Durability and retries

This is the real decision axis, and it’s where the most common misconception lives: apalis is not automatically more crash-safe.

  • tokio-cron-scheduler keeps schedules in memory. A restart forgets them until your code re-adds them on boot — fine for code-defined jobs, a problem for dynamic ones. The optional postgres_storage / nats_storage features persist the schedule definitions, not a durable record of each individual run. Retries and overlap handling are yours to implement.
  • apalis-cron’s CronStream also fires in memory — the trigger itself is no more durable than tokio-cron-scheduler’s. apalis earns its reliability only when you route the emitted jobs through a storage backendapalis-redis or apalis-sql (PostgreSQL/MySQL/SQLite). Then each firing becomes a persisted job with at-least-once delivery, and retries come for free through Tower middleware (RetryPolicy), alongside timeouts, concurrency limits, and tracing.

So the durability question is really: are you willing to run Redis or a SQL queue? If yes and the work is important, apalis’s per-run durability and built-in retries are worth the weight. If no, tokio-cron-scheduler is the lighter, infra-free choice — and you accept that a crash mid-run loses that run.

The same job in both

An hourly job, written each way. First tokio-cron-scheduler — the scheduler owns the loop:

use tokio_cron_scheduler::{Job, JobScheduler};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let sched = JobScheduler::new().await?;

    // 6-field: sec min hour dom month dow
    sched.add(Job::new_async("0 0 * * * *", |_uuid, _lock| {
        Box::pin(async move { hourly_task().await; })
    })?).await?;

    sched.start().await?;
    tokio::signal::ctrl_c().await?;
    sched.shutdown().await?;
    Ok(())
}

Now apalis-cron — a worker consuming a cron-driven backend, with a retry layer:

use apalis::{prelude::*, layers::retry::RetryPolicy};
use apalis_cron::{CronStream, Schedule};
use chrono::Local;
use std::str::FromStr;

#[derive(Default, Debug, Clone)]
struct Tick;

async fn hourly_task(_job: Tick, _ctx: CronContext<Local>) {
    // ... the work ...
}

#[tokio::main]
async fn main() {
    // 7-field: note the trailing year
    let schedule = Schedule::from_str("0 0 * * * * *").unwrap();

    let worker = WorkerBuilder::new("hourly")
        .retry(RetryPolicy::retries(3))          // built-in, not hand-rolled
        .backend(CronStream::new(schedule))
        .build_fn(hourly_task);

    worker.run().await;
}

The apalis version is more ceremony for the same trigger — the payoff is the middleware stack and, once you add a storage backend, durable per-run processing. For a truly durable setup you’d route jobs through apalis-redis/apalis-sql; see the apalis guide.

Using them together

They aren’t mutually exclusive — the strongest setup often uses both. Let tokio-cron-scheduler be the trigger and have its closure enqueue an apalis job:

// tokio-cron-scheduler fires on time…
sched.add(Job::new_async("0 0 * * * *", move |_uuid, _lock| {
    let mut storage = storage.clone();     // apalis-redis / apalis-sql
    Box::pin(async move {
        // …and hands a durable job to apalis for reliable processing.
        storage.push(GenerateReport { /* … */ }).await.ok();
    })
})?).await?;

This splits the two concerns cleanly: tokio-cron-scheduler answers “when should this run?” with a lightweight in-process timer, and apalis answers “reliably do the work, with retries and observability.” You skip apalis-cron entirely and get the best of each — simple scheduling plus durable execution.

Which to choose

tokio-cron-schedulerapalis (+ apalis-cron)
CategoryIn-process async schedulerBackground-job framework
Latest release0.15.1 (stable)0.7.4 stable (1.0 in RC)
AdoptionVery high (de-facto async scheduler)Steady, smaller
Cron format6-field croner (no year)7-field cron crate + macros
Sub-minuteYesYes
Trigger durabilityIn-memoryIn-memory (CronStream)
Per-run durabilityNo (schedule persistence only)Yes, via Redis/SQL storage
RetriesManualBuilt-in (Tower middleware)
External infraNoneRedis or SQL for durability
Distributed workersNoYes
Interval / one-shot jobsYesCron-driven (queue backends for the rest)
WeightLightHeavier

Decision rule: if losing an occasional run on restart is acceptable, use tokio-cron-scheduler — it’s lighter, needs no infrastructure, and covers most periodic work. If a missed or failed run is a real problem, use apalis with a storage backend for durability and retries. And when you want both simple scheduling and durable processing, compose them: tokio-cron-scheduler to trigger, apalis to execute.

For the full landscape of Rust scheduling crates — including the cron crate for parsing and clokwerk for interval DSLs — see the Rust cron scheduling guide.

Frequently asked questions

tokio-cron-scheduler or apalis — which should I use?
Use tokio-cron-scheduler for lightweight, in-process periodic tasks when losing a run on restart is acceptable — cache refreshes, heartbeats, digests. Use apalis when scheduled work is a durable unit that must survive crashes, retry on failure, or spread across multiple workers — billing, payouts, report generation. tokio-cron-scheduler schedules work; apalis processes durable jobs that happen to be cron-triggered.
Is apalis actually more reliable than tokio-cron-scheduler?
Only when you wire it to a storage backend. apalis-cron's CronStream fires in memory, just like tokio-cron-scheduler — the trigger itself isn't durable. apalis's reliability comes from routing the emitted jobs through apalis-redis or apalis-sql, which persists each job and gives at-least-once, retried processing. Without a storage backend, apalis-cron is no more crash-safe than tokio-cron-scheduler.
Do tokio-cron-scheduler and apalis use the same cron format?
No, and it's a common porting bug. tokio-cron-scheduler (via croner) uses a 6-field, seconds-first format with no year: `second minute hour day-of-month month day-of-week`. apalis-cron (via the cron crate) uses a 7-field format that adds a trailing year, plus macros like @daily. "Every 5 minutes" is `0 */5 * * * *` in tokio-cron-scheduler and `0 */5 * * * * *` in apalis.
Can I use tokio-cron-scheduler and apalis together?
Yes, and it's a strong pattern. Use tokio-cron-scheduler as the lightweight trigger, and have its job enqueue an apalis job into Redis or SQL. You get simple in-process scheduling plus apalis's durable, retried, observable processing — without running apalis-cron. This separates "when to run" from "reliably doing the work."
Which is more widely used?
tokio-cron-scheduler is downloaded far more — on the order of several times apalis's recent downloads — because it fits the common case of periodic in-process tasks. apalis has a smaller but steady base among services that need durable background jobs. Popularity reflects use case breadth, not quality; pick on durability needs, not download counts.