cronuru
Guide

Scheduled Tasks in Axum & Actix Web Services

You have a Rust web service on Axum or Actix, and you need it to also run work on a schedule — clean up expired rows every hour, send a digest each morning, refresh a cache every few minutes. The key idea is that the scheduler and the HTTP server share **one Tokio runtime**: you start the scheduler, hand control to the server, and shut both down together. This guide covers that pattern in both frameworks, plus the parts people actually get stuck on — sharing your app state with jobs, running background work from a request handler, and making jobs durable.

Updated

The core idea

A web service that also schedules work has two long-running things: the HTTP server and the scheduler. The mistake is treating them as separate programs — spinning up a second Tokio runtime for the scheduler, or blocking the main thread on a scheduling loop.

Don’t. Axum and Actix both run on Tokio, and a scheduler like tokio-cron-scheduler runs on that same runtime. The pattern is always:

  1. Build and start the scheduler (it spawns its own background task and returns immediately).
  2. Start the server and await it — this is what keeps the process alive.
  3. When the server stops, shut the scheduler down so in-flight jobs drain.

Everything below is that shape, filled in for each framework.

Scheduling in Axum

The minimal version — an hourly job next to an Axum server, on one runtime:

use axum::{routing::get, Router};
use tokio_cron_scheduler::{Job, JobScheduler};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // 1. Scheduler — starts a background task, returns immediately.
    let sched = JobScheduler::new().await?;
    sched.add(Job::new_async("0 0 * * * *", |_uuid, _lock| {
        Box::pin(async move { hourly_cleanup().await; })
    })?).await?;
    sched.start().await?;

    // 2. Server — this future is what keeps the process running.
    let app = Router::new().route("/health", get(|| async { "ok" }));
    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
    axum::serve(listener, app)
        .with_graceful_shutdown(shutdown_signal())
        .await?;

    // 3. Server stopped — drain scheduled jobs.
    sched.shutdown().await?;
    Ok(())
}

async fn shutdown_signal() {
    tokio::signal::ctrl_c().await.expect("failed to install Ctrl-C handler");
}

Note the cron string is 6-field (0 0 * * * * = top of every hour) — tokio-cron-scheduler puts seconds first. That’s the #1 porting gotcha; check any expression in the cron parser first.

Sharing app state with jobs

This is the part that trips people up. Your handlers get the database pool through Axum’s State, and your scheduled job usually needs the same pool. The catch: a Job::new_async closure is called every time the schedule fires, so it must be reusable — you clone your state into the closure, then clone it again inside for each run’s future.

use std::sync::Arc;
use axum::{extract::State, routing::get, Router};
use tokio_cron_scheduler::{Job, JobScheduler};

#[derive(Clone)]
struct AppState {
    db: sqlx::PgPool,
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let state = AppState { db: make_pool().await? };

    let sched = JobScheduler::new().await?;

    // Clone once into the closure…
    let job_state = state.clone();
    sched.add(Job::new_async("0 */5 * * * *", move |_uuid, _lock| {
        // …and again for each firing, since the future owns it.
        let state = job_state.clone();
        Box::pin(async move {
            purge_expired_sessions(&state.db).await;
        })
    })?).await?;
    sched.start().await?;

    let app = Router::new()
        .route("/health", get(|| async { "ok" }))
        .with_state(state); // handlers share the same pool
    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
    axum::serve(listener, app).await?;

    sched.shutdown().await?;
    Ok(())
}

The double clone looks redundant but each serves a purpose: the outer job_state is moved into the Fn closure so it can be called repeatedly; the inner state is a fresh clone the async move block takes ownership of on each run. A PgPool (or any Arc-backed handle) is cheap to clone — it’s a reference count bump, not a new connection pool.

Scheduling in Actix Web

Actix Web also runs on Tokio, so the same scheduler works — start it before HttpServer::run():

use actix_web::{web, App, HttpServer};
use tokio_cron_scheduler::{Job, JobScheduler};

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    let sched = JobScheduler::new().await.unwrap();
    sched.add(Job::new_async("0 0 * * * *", |_uuid, _lock| {
        Box::pin(async move { hourly_cleanup().await; })
    }).unwrap()).await.unwrap();
    sched.start().await.unwrap();

    HttpServer::new(|| {
        App::new().route("/health", web::get().to(|| async { "ok" }))
    })
    .bind(("0.0.0.0", 8080))?
    .run()
    .await
    // Actix handles SIGINT/SIGTERM itself; run() returns once the server stops.
}

One caveat specific to Actix: #[actix_web::main] runs on a single-threaded Tokio runtime (actix-rt forces current-thread mode). A scheduled job that does blocking I/O or heavy CPU work will stall the same thread that serves requests. Keep jobs async and non-blocking, and push anything blocking onto a thread pool with tokio::task::spawn_blocking. To share state, register it with App::app_data(web::Data::new(...)) for handlers and clone a separate handle into the job closure, exactly as in the Axum example.

Background tasks from a handler

Not all background work is scheduled — sometimes a request needs to kick off work and return immediately without waiting for it (send a welcome email, start a report). Spawn it onto the runtime and respond:

use axum::{extract::State, http::StatusCode};

async fn request_report(State(state): State<AppState>) -> StatusCode {
    // Fire-and-forget: the response returns now; the work runs after.
    tokio::spawn(async move {
        generate_report(&state.db).await;
    });
    StatusCode::ACCEPTED // 202 — accepted, not yet done
}

tokio::spawn hands the future to the runtime and returns a handle you can ignore. Two honest caveats: a spawned task is not durable (a crash loses it), and it has no backpressure (a flood of requests spawns a flood of tasks). If either matters, enqueue a durable job instead of spawning.

When you don’t need a scheduler

For a single fixed-interval task, a cron scheduler is overkill. A tokio::spawn loop around tokio::time::interval covers it with zero dependencies:

use std::time::Duration;

// Somewhere during startup, before serving:
tokio::spawn(async move {
    let mut ticker = tokio::time::interval(Duration::from_secs(60));
    loop {
        ticker.tick().await;      // first tick fires immediately
        heartbeat().await;
    }
});

Reach for tokio-cron-scheduler once you have several jobs, need real cron expressions or specific wall-clock times, want per-job timezones, or want its built-in graceful shutdown. For one heartbeat, the loop above is the idiomatic choice. (interval fires the first tick right away — call ticker.tick().await once before the loop body if you want to wait a full period first.)

Making jobs durable

Everything so far is in-memory: if the process crashes mid-job, that run is gone. For work you can’t afford to drop — invoicing, payment retries, email sends — the scheduled trigger should hand the actual work to a durable queue like apalis (Redis- or SQL-backed):

// tokio-cron-scheduler fires on time…
let mut storage = apalis_storage.clone();
sched.add(Job::new_async("0 0 * * * *", move |_uuid, _lock| {
    let mut storage = storage.clone();
    Box::pin(async move {
        // …and enqueues a durable job instead of doing the work inline.
        storage.push(GenerateInvoices { period: "monthly".into() }).await.ok();
    })
})?).await?;

Now the scheduler only answers “when,” and apalis owns “reliably do it,” with persistence and retries. That separation is the compose pattern from the tokio-cron-scheduler vs apalis comparison — the recommended shape for important scheduled work in a web service.

Graceful shutdown

A clean shutdown does two things in order: stop accepting new HTTP requests (letting in-flight ones finish), then stop the scheduler (letting running jobs finish). In Axum, with_graceful_shutdown handles the first; your sched.shutdown().await after it handles the second:

axum::serve(listener, app)
    .with_graceful_shutdown(shutdown_signal())
    .await?;
sched.shutdown().await?; // runs only after the server has drained

A shutdown_signal that also catches SIGTERM (what container orchestrators send) is worth the few extra lines:

async fn shutdown_signal() {
    let ctrl_c = async { tokio::signal::ctrl_c().await.ok(); };

    #[cfg(unix)]
    let terminate = async {
        tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
            .expect("install SIGTERM handler").recv().await;
    };
    #[cfg(not(unix))]
    let terminate = std::future::pending::<()>();

    tokio::select! {
        _ = ctrl_c => {},
        _ = terminate => {},
    }
}

In Actix, HttpServer installs its own signal handling, so run().await returns on SIGINT/SIGTERM; call sched.shutdown().await right after it returns.

Pitfalls

  • Two runtimes. Don’t wrap the scheduler in its own Runtime::new() — it shares the server’s runtime. #[tokio::main] (Axum) and #[actix_web::main] (Actix) already give you one.
  • Blocking a worker. A scheduled job that runs blocking I/O or heavy CPU work stalls the same threads serving HTTP. Offload with tokio::task::spawn_blocking. This bites hardest on Actix’s single-threaded runtime.
  • The double clone. Sharing state with a job needs two clones — one into the Fn closure, one into each run’s future. Forgetting the inner clone is the most common borrow-checker fight here.
  • 6-field cron. tokio-cron-scheduler expects seconds first (0 0 * * * *, not 0 * * * *). Pasting a Unix 5-field expression misfires silently.
  • In-memory by default. Spawned tasks and in-memory scheduler jobs don’t survive a crash. Use a durable queue for anything that must not be lost.
  • Forgetting shutdown. Without sched.shutdown().await, a deploy can kill a job mid-run. Drain the scheduler after the server stops.

For the scheduler itself in depth, see the tokio-cron-scheduler guide; for durable jobs, apalis; for the wider picture, Rust cron scheduling.

Frequently asked questions

How do I run a cron job in an Axum web service?
Start a tokio-cron-scheduler `JobScheduler` before you start the server, add jobs with `Job::new_async`, call `sched.start().await`, then serve with `axum::serve(...)`. Both run on the same `#[tokio::main]` runtime, so no extra threads or runtimes are needed. After the server stops, call `sched.shutdown().await` to drain in-flight jobs.
Can I share my Axum app state (like a database pool) with a scheduled job?
Yes. Clone the state into the job closure, then clone it again inside for each firing, because the closure is called repeatedly and each run's future takes ownership: `let job_state = state.clone(); sched.add(Job::new_async(expr, move |_,_| { let s = job_state.clone(); Box::pin(async move { work(&s.db).await; }) })?)`. The job gets the same pool your handlers use.
How do I run a background task in Actix Web?
Actix Web runs on Tokio, so the same tokio-cron-scheduler pattern works: create and start the scheduler before `HttpServer::new(...).run().await`. Note that `#[actix_web::main]` uses a single-threaded runtime, so keep scheduled jobs non-blocking and offload any blocking or CPU-heavy work with `tokio::task::spawn_blocking`.
Should I use a scheduler or just tokio::spawn with an interval?
For a single fixed-interval task ("every 60 seconds"), a `tokio::spawn` loop around `tokio::time::interval` is simpler and needs no dependency. Reach for tokio-cron-scheduler when you have multiple jobs, want real cron expressions or specific wall-clock times, need per-job timezones, or want built-in graceful shutdown.
How do I make scheduled jobs survive a restart without losing work?
tokio-cron-scheduler holds jobs in memory, so a crash mid-run loses that run. For work that must not be dropped — billing, emails, report generation — have the scheduled job enqueue an apalis job backed by Redis or SQL, which persists and retries each unit of work independently of the web process.