cronuru
Guide

apalis: Durable Scheduled Jobs in Rust

`apalis` is a background job framework for Rust — and `apalis-cron` lets you drive those jobs on a cron schedule. Unlike an in-process scheduler, apalis is built around storage backends (Redis, PostgreSQL, SQLite), retry policies, and Tower middleware, which makes it the right tool when scheduled work must survive restarts and failures. This guide is accurate to apalis 0.7.

Updated

What apalis is

apalis isn’t a cron scheduler — it’s a background job framework, in the same family as Sidekiq (Ruby) or Celery (Python). Jobs are produced by a backend and consumed by workers, with Tower middleware layered in for retries, timeouts, concurrency control, and tracing.

apalis-cron is one backend among several: instead of pulling jobs from a queue, it emits a job every time a cron schedule fires. That means you get cron triggering plus apalis’s durability and retry machinery — which is the whole reason to choose it over a plain in-process scheduler like tokio-cron-scheduler.

Add it to your project

[dependencies]
apalis = "0.7"
apalis-cron = "0.7"
tokio = { version = "1", features = ["full"] }
chrono = "0.4"

The latest stable line is 0.7 (a 1.0 release is in the release-candidate stage, so expect some API churn when it lands — pin your version).

A cron-driven worker

The message type is just a plain struct (it needs Default, Debug, and Clone). You parse a schedule, wrap it in a CronStream, and build a worker whose build_fn is your async handler:

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

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

async fn handle_tick(_job: Reminder, _ctx: CronContext<Local>, data: Data<usize>) {
    // This runs each time the schedule fires.
    println!("tick — shared data = {}", *data);
}

#[tokio::main]
async fn main() {
    let schedule = Schedule::from_str("@daily").unwrap();

    let worker = WorkerBuilder::new("morning-reminder")
        .data(42usize)                       // shared state injected into the handler
        .backend(CronStream::new(schedule))
        .build_fn(handle_tick);

    worker.run().await;
}

The handler receives three things: the job message, a CronContext<Local> (the firing time and timezone), and any Data<T> you registered with .data(...).

The cron format apalis uses

apalis-cron parses schedules with the Rust cron crate, which uses a 7-field, seconds-first layout — second minute hour day-of-month month day-of-week year — and also accepts shorthand macros.

This is a real difference from tokio-cron-scheduler (which is 6-field): apalis adds an optional trailing year field.

Scheduleapalis (cron crate)Macro
Every 5 minutes0 */5 * * * * *
Every hour0 0 * * * * *@hourly
Midnight daily0 0 0 * * * *@daily
9 AM weekdays0 0 9 * * Mon-Fri *
Weekly (Sun 00:00)0 0 0 * * Sun *@weekly

If the year field is omitted, the cron crate accepts a 6-field form too, but writing it out keeps the intent explicit. Unsure what an expression does? Run it through the cron parser first.

Durability with storage

On its own, CronStream fires in memory — if the process dies mid-job, that run is gone. apalis’s answer is to route jobs through a storage backend so they’re persisted, acknowledged, and retried independently of any single process:

  • apalis-redis — Redis-backed queue. Fast, simple, the common choice.
  • apalis-sql — PostgreSQL, MySQL, or SQLite storage, if you’d rather not run Redis.

This is the architectural reason to pick apalis: a scheduled job becomes a durable unit of work with an at-least-once guarantee, instead of a fire-and-forget tick. For jobs you genuinely cannot drop — invoicing, payouts, report generation — that durability is the point. See the apalis documentation for wiring a storage backend to a worker, as the exact API is evolving toward 1.0.

Retries and middleware

Because apalis is built on Tower, resilience is a layer you add rather than code you write. A retry policy, for instance:

use apalis::{prelude::*, layers::retry::RetryPolicy};

let worker = WorkerBuilder::new("morning-reminder")
    .retry(RetryPolicy::retries(5))          // retry a failing run up to 5 times
    .data(42usize)
    .backend(CronStream::new(schedule))
    .build_fn(handle_tick);

The same mechanism layers in timeouts, concurrency limits, rate limiting, and tracing spans. This is what you give up with a bare scheduler — and what makes apalis worth its extra weight for important work.

Running and shutting down

A single worker runs with worker.run().await. To run several workers together — and to get coordinated, graceful shutdown on SIGINT/SIGTERM — apalis provides a Monitor that you register workers on and run as a unit. Reach for Monitor once you have more than one worker or need to manage shutdown explicitly; check the current apalis docs for the exact builder API, which is changing ahead of 1.0.

In a web service, run the apalis worker (or monitor) and your HTTP server concurrently with tokio::join!, and make sure both stop on the same shutdown signal so in-flight jobs drain cleanly.

apalis vs tokio-cron-scheduler

They solve overlapping problems at different weights:

tokio-cron-schedulerapalis (+ apalis-cron)
ModelIn-process async schedulerBackground job framework
Cron format6-field (seconds-first)7-field (cron crate) + macros
DurabilityIn-memory; optional Postgres/NATS for schedulesRedis / SQL storage; durable per-run
RetriesManualBuilt-in middleware
Best forLightweight periodic tasksImportant jobs that can’t be dropped
WeightLightHeavier (storage, infra)

Rule of thumb: if losing an occasional run on restart is fine, tokio-cron-scheduler is simpler. If a missed or failed run is a real problem, apalis’s durability and retries earn their keep. For the wider picture of Rust scheduling options, see the Rust cron scheduling guide.

Frequently asked questions

What is apalis in Rust?
apalis is a simple, extensible, multithreaded background job-processing framework for Rust, built on Tower middleware. Jobs are produced by a backend and consumed by workers, with built-in support for retries, concurrency limits, and tracing. The apalis-cron crate is one such backend that emits jobs on a cron schedule.
How do I run a cron job with apalis?
Add apalis and apalis-cron, parse a schedule with `Schedule::from_str`, then build a worker with `WorkerBuilder::new(name).backend(CronStream::new(schedule)).build_fn(handler)` and call `worker.run().await`. The handler is an async function that receives the job message, a `CronContext`, and any shared `Data`.
What cron format does apalis use?
apalis-cron parses schedules with the Rust `cron` crate, which uses a 7-field, seconds-first format (second, minute, hour, day-of-month, month, day-of-week, year) and also accepts macros like `@daily` and `@hourly`. Note this differs from tokio-cron-scheduler's 6-field format — apalis includes an optional trailing year field.
Does apalis survive restarts?
apalis-cron's stream itself is in-memory, but apalis is designed to pair a job source with a durable storage backend — apalis-redis (Redis) or apalis-sql (PostgreSQL, MySQL, SQLite). When jobs flow through a storage backend, they're persisted and retried across restarts and crashes, which is the main reason to choose apalis over an in-process scheduler.
apalis or tokio-cron-scheduler — which should I use?
Use tokio-cron-scheduler for lightweight in-process scheduling when losing a run on restart is acceptable. Use apalis when scheduled work is important enough to need durability, retries, and observability — for example billing runs, report generation, or anything you can't afford to silently drop.