Oct 28, 20266 min read

How to Add Cron Jobs to an Actix Web App

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:

  • Whether the schedule runs inside the app or calls it from outside.
  • How you stop several replicas from running the same job.
  • How each job behaves when it runs twice.

Cron options for Actix Web at a glance

CapabilityEncoretokio-cron-schedulerEventBridge Scheduler
Where the schedule livesA Job static in your codeA Tokio task in the processAWS, configured outside the code
ReplicasEncore calls the route on scheduleEach replica runs itOne call per run
TriggerSigned request to an Actix routeIn-process functionLambda, ECS task or API destination
LocalCall the route with curlRuns with the appInvoke by hand
VisibilityRuns listed with tracesYour logsCloudWatch

Encore: cron jobs for your existing Actix Web app

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:

~/accounts
$ encore run$ curl -X POST localhost:4000/jobs/cleanup-sessions{"deleted":1}$ curl -X POST localhost:4000/jobs/weekly-digest{"sent":true}$ curl -X POST localhost:4000/jobs/weekly-digest{"sent":false}$ curl -X POST localhost:4000/jobs/cleanup-sessions{"deleted":0}

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.

Try Encore →

tokio-cron-scheduler

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?;

Good to know

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.

Try tokio-cron-scheduler →

EventBridge Scheduler

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.

Good to know

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.

Try EventBridge Scheduler →

How to choose

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.

Frequently asked questions

How do I run a cron job in an 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.

Why does my in-process cron job run several times?

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.

What time zone do cron schedules use?

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.

~/orders — claude
Claude Code
Claude Code v2.1.180Opus 5.5~/orders