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:
- Build and start the scheduler (it spawns its own background task and returns immediately).
- Start the server and await it — this is what keeps the process alive.
- 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
Fnclosure, 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 * * * *, not0 * * * *). 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?
Can I share my Axum app state (like a database pool) with a scheduled job?
How do I run a background task in Actix Web?
Should I use a scheduler or just tokio::spawn with an interval?
How do I make scheduled jobs survive a restart without losing work?
Related
Every 5 Minutes
`0 */5 * * * *` — a common in-service refresh interval.
ToolCron Parser
Check what a schedule fires before wiring it into your service.
Guidetokio-cron-scheduler
The async scheduler used throughout this guide, in full.
Guideapalis: Durable Jobs in Rust
For scheduled work that must survive restarts and retry.
GuideRust Cron Scheduling
The full landscape of Rust scheduling crates.
Guidetokio-cron-scheduler vs apalis
Which to pick for the scheduling half of your service.