Running scheduled work in an Actix Web 4 app with Encore, tokio-cron-scheduler or EventBridge Scheduler, with jobs that are safe to run twice and do not overlap.
An Actix Web app often needs work that no request triggers: deleting idle sessions at night, refreshing exchange rates every fifteen minutes, sending a digest on Monday morning. The schedule itself is one cron expression, and the decisions are about where the schedule lives, what happens when the app runs on several instances, and whether a job that runs twice does any harm.
This guide adds cron jobs to an Actix Web 4 app with Encore, tokio-cron-scheduler and EventBridge Scheduler. The examples run three jobs against Postgres: a daily cleanup, an interval job that must not overlap, and a weekly digest that must only send once.
What you need to decide:
Encore combines an open source infrastructure SDK for TypeScript, Go, Python and Rust with a platform that deploys into your own AWS or GCP account. With the Rust SDK, a cron job is a static that names a path in your Actix app and a schedule. The job runs your normal handler, and once the app is deployed Encore calls that handler on the schedule.
use actix_web::{web, App, HttpResponse, HttpServer};
use encore::cron::{Job, Schedule};
use encore::storage::postgres::Database;
static DB: Database = Database::new("accounts").migrations("./migrations");
// Cron expressions are five fields in UTC: 03:00 every day.
static CLEANUP_SESSIONS: Job =
Job::new("cleanup-sessions", "/jobs/cleanup-sessions", Schedule::Cron("0 3 * * *"))
.title("Delete idle sessions");
// Intervals are laid out from midnight UTC and must divide 24 hours evenly.
static REFRESH_RATES: Job =
Job::new("refresh-rates", "/jobs/refresh-rates", Schedule::Every("15m"));
// 08:00 UTC on Mondays.
static WEEKLY_DIGEST: Job =
Job::new("weekly-digest", "/jobs/weekly-digest", Schedule::Cron("0 8 * * 1"));
Each job is an ordinary Actix handler, routed with the job's own path() so the two never drift apart. The cleanup can run twice without harm, since a second run finds nothing to delete. The rates job takes a Postgres advisory lock and skips the run if the previous one is still going. The digest records a row per week, so a second call sends nothing:
async fn cleanup_sessions() -> HttpResponse {
// Safe to run twice: the second run finds nothing left to delete.
match DB
.execute("DELETE FROM sessions WHERE last_seen < now() - interval '30 days'", &[])
.await
{
Ok(deleted) => HttpResponse::Ok().json(serde_json::json!({ "deleted": deleted })),
Err(_) => HttpResponse::InternalServerError().finish(),
}
}
async fn try_refresh_rates() -> Result<serde_json::Value, encore::Error> {
let tx = DB.transaction().await?;
// Skip this run if the previous one still holds the lock.
let row = tx.query_one("SELECT pg_try_advisory_xact_lock(42)", &[]).await?;
if !row.get::<_, bool>(0) {
tx.rollback().await?;
return Ok(serde_json::json!({ "skipped": true }));
}
// Stands in for a call to a rates API.
tx.execute(
"INSERT INTO exchange_rates (currency, rate, updated_at) VALUES ('EUR', 0.92, now()) \
ON CONFLICT (currency) DO UPDATE SET rate = EXCLUDED.rate, updated_at = now()",
&[],
)
.await?;
tx.commit().await?;
Ok(serde_json::json!({ "skipped": false }))
}
async fn try_weekly_digest() -> Result<serde_json::Value, encore::Error> {
// One marker row per week, so a repeated request sends nothing.
let first_time = DB
.execute(
"INSERT INTO digests_sent (week) VALUES (date_trunc('week', now())::date) \
ON CONFLICT DO NOTHING",
&[],
)
.await?;
if first_time == 1 {
println!("sending weekly digest");
}
Ok(serde_json::json!({ "sent": first_time == 1 }))
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
encore::initialize();
let addr = std::env::var("ENCORE_LISTEN_ADDR").unwrap_or("0.0.0.0:8080".into());
HttpServer::new(|| {
App::new()
.route(CLEANUP_SESSIONS.path(), web::post().to(cleanup_sessions))
.route(REFRESH_RATES.path(), web::post().to(refresh_rates))
.route(WEEKLY_DIGEST.path(), web::post().to(weekly_digest))
})
.bind(addr)?
.run()
.await
}
refresh_rates and weekly_digest wrap the two try_ functions and turn an error into a 500. Cron jobs do not fire under encore run, so you test a job by calling its route, which also shows that a second run is harmless:
When deployed, Encore calls the job's path according to the schedule and manages scheduling, monitoring and execution. Cron jobs also do not run in preview environments. Each run shows up with its trace, and a slow cleanup query is visible next to the job that ran it.
tokio-cron-scheduler runs jobs on the same Tokio runtime as Actix. You build a scheduler in main, add jobs and start it before the server:
use tokio_cron_scheduler::{Job, JobScheduler};
let sched = JobScheduler::new().await?;
// Six fields with seconds: 03:00:00 every day.
sched
.add(Job::new_async("0 0 3 * * *", move |_id, _sched| {
let pool = pool.clone();
Box::pin(async move {
let _ = sqlx::query("DELETE FROM sessions WHERE last_seen < now() - interval '30 days'")
.execute(&pool)
.await;
})
})?)
.await?;
sched.start().await?;
The scheduler lives in the process, so every replica runs every job and a restart during a run loses it. Use the same advisory lock or marker row as above if you run more than one instance. Note the extra seconds field compared with standard cron.
On AWS, EventBridge Scheduler calls a target on a cron or rate schedule. For an Actix app on ECS, the usual targets are an API destination that calls a job route or a one-off ECS task that runs a binary. You define the schedule, the target, an IAM role the scheduler assumes, and a shared secret or signature so the route rejects calls that do not come from the scheduler.
The schedule lives outside the code, usually in Terraform, so a job and its route can be changed separately. Retries and a dead-letter queue are configured on the schedule. The EventBridge Scheduler guide covers targets and roles.
Pick Encore when you want schedules declared next to the Actix routes they call, with Encore calling them on schedule and runs visible in traces, deployed into your own AWS or GCP account. Pick tokio-cron-scheduler for a single-instance app where a missed run does not matter. Pick EventBridge Scheduler when your platform team already manages schedules in Terraform.
Whichever you pick, make each job safe to run twice and keep long jobs from overlapping.
You can sign up for early access to Encore for Rust to try it with your Actix Web app.
Either run a scheduler inside the process, such as tokio-cron-scheduler, or have something outside call an endpoint on a schedule. With Encore, declare static CLEANUP: Job = Job::new("cleanup-sessions", "/jobs/cleanup-sessions", Schedule::Cron("0 3 * * *")) and route that path to an Actix handler; Encore calls it on schedule in your AWS or GCP account.
Every replica of the app runs its own scheduler, so three instances run the job three times. Use an external scheduler that calls one instance, or take a Postgres advisory lock so only one run proceeds.
EventBridge Scheduler evaluates cron expressions in UTC unless you set a time zone on the schedule, and a scheduler inside the app uses whatever time zone it is configured with. Writing every schedule in UTC and converting local times explicitly avoids surprises around daylight saving changes.
Deploy to your cloud on AWS and Google Cloud
without managing infrastructure
Encore lets developers and agents define application infrastructure directly in code, then automatically provisions it from local development to production in your AWS or GCP account.