Oct 26, 20268 min read

How to Add Background Jobs to an Actix Web App

Running work outside the request in Actix Web 4 with Encore, apalis with Redis, or tokio::spawn and tokio-cron-scheduler, with retries, idempotent jobs and scheduled tasks.

Actix Web handles requests quickly, and most Actix services eventually need work that runs outside the request, such as receipts, webhooks or an hourly cleanup. Rust has no default for this, so each team picks its own mix of tokio::spawn, job libraries and cloud queues.

This guide uses Actix Web 4 (4.18 at the time of writing) and adds a queued receipt job and an hourly expiry task. It covers Encore, apalis with Redis, and tokio::spawn with tokio-cron-scheduler.

What you need to decide:

  • Whether jobs survive a restart or a deploy.
  • How failures are retried, and how often.
  • How a job that runs twice avoids repeating its side effect.
  • Where scheduled tasks run when there are several instances.

Background job options for Actix Web at a glance

CapabilityEncoreapalis with Redistokio::spawn and tokio-cron-scheduler
DurabilityDurable queue, at least onceStored in Redis, at least onceIn memory only
Retriesmax_retries and min_backoff per subscriptionRetry layer per workerWrite it yourself
Scheduled taskscron::Job calling a routeCron source in apalis-cronIn-process scheduler
Multiple instancesSchedule runs once per environmentJobs shared, cron per process unless coordinatedEvery instance runs the schedule
Local setupencore run delivers jobsRun RedisNone
ProductionSNS and SQS or GCP Pub/Sub in your accountYou run Redis and workersSame process

Encore: background jobs for your existing Actix Web app

Encore

Encore combines job queues and scheduled tasks declared in your Actix Web code with the infrastructure behind them, delivering jobs locally under encore run and running them on SNS and SQS or Pub/Sub, with a managed schedule, in your own cloud account after deploy. It is an infrastructure SDK, the encore-dev crate, added to an existing app, and the handlers, extractors and App setup stay as they are.

Encore declares infrastructure in code, runs it locally and provisions it in your AWS or GCP account
Infrastructure declared in code, running locally under encore run and provisioned in your own AWS or GCP account on deploy.
[dependencies] encore-dev = "0.1" actix-web = "4" serde = { version = "1", features = ["derive"] }

The migration creates the orders table and a receipts_sent table, which the job uses to know what it has already done:

-- src/migrations/1_create_orders.up.sql CREATE TABLE orders ( id BIGSERIAL PRIMARY KEY, email TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending', created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE TABLE receipts_sent ( order_id BIGINT PRIMARY KEY REFERENCES orders (id) );

The queue is a topic, and the worker is a subscription registered with #[encore::pubsub::register], which encore::initialize() starts with the app. A handler that returns an error is retried up to five times, starting at a five second backoff, and the handler checks receipts_sent before sending so a redelivered job does not email twice:

use std::time::Duration; use actix_web::{post, web, App, HttpResponse, HttpServer, Responder}; use encore::cron::{Job, Schedule}; use encore::pubsub::{Message, Subscription, Topic}; use encore::storage::postgres::Database; use serde::{Deserialize, Serialize}; static DB: Database = Database::new("orders").migrations("./migrations"); #[derive(Debug, Clone, Serialize, Deserialize)] struct OrderPlaced { order_id: i64, email: String, } // The job queue: SNS/SQS on AWS, Pub/Sub on GCP, built in under encore run. static ORDER_PLACED: Topic<OrderPlaced> = Topic::new("order-placed"); // The worker, started with the app by encore::initialize(). // A handler that returns an error is retried with backoff. #[encore::pubsub::register] static SEND_RECEIPT: Subscription<OrderPlaced> = Subscription::new(&ORDER_PLACED, "send-receipt", send_receipt) .min_backoff(Duration::from_secs(5)) .max_retries(5); // Delivery is at least once, so the handler checks receipts_sent first // and a redelivered message does not send a second email. async fn send_receipt(msg: Message<OrderPlaced>) -> Result<(), encore::Error> { let order = &msg.data; if DB .query_opt("SELECT 1 FROM receipts_sent WHERE order_id = $1", &[&order.order_id]) .await? .is_some() { return Ok(()); } send_email(&order.email, order.order_id).await?; DB.execute( "INSERT INTO receipts_sent (order_id) VALUES ($1) ON CONFLICT DO NOTHING", &[&order.order_id], ) .await?; Ok(()) } // Placeholder for your email provider. async fn send_email(to: &str, order_id: i64) -> Result<(), encore::Error> { println!("receipt for order {order_id} sent to {to}"); Ok(()) }

The handler saves the order, publishes the job and returns 202 without waiting for the email:

#[derive(Deserialize)] struct NewOrder { email: String, } #[post("/orders")] async fn place_order(body: web::Json<NewOrder>) -> impl Responder { let row = match DB .query_one("INSERT INTO orders (email) VALUES ($1) RETURNING id", &[&body.email]) .await { Ok(row) => row, Err(_) => return HttpResponse::InternalServerError().finish(), }; let order_id: i64 = row.get("id"); let job = OrderPlaced { order_id, email: body.email.clone() }; match ORDER_PLACED.publish(&job).await { Ok(_) => HttpResponse::Accepted().body(format!("order {order_id} placed\n")), Err(_) => HttpResponse::InternalServerError().finish(), } }

The hourly task is a cron::Job that calls a route. EXPIRE_ORDERS.path() registers the handler on the same path the job calls, and the UPDATE only touches pending orders, so running it twice changes nothing:

// Cancels orders left pending for a day, every hour in deployed environments. static EXPIRE_ORDERS: Job = Job::new("expire-orders", "/internal/expire-orders", Schedule::Every("1h")) .title("Expire stale pending orders"); async fn expire_orders() -> impl Responder { match DB .execute( "UPDATE orders SET status = 'expired' \ WHERE status = 'pending' AND created_at < now() - interval '1 day'", &[], ) .await { Ok(n) => HttpResponse::Ok().body(format!("expired {n} orders\n")), Err(_) => HttpResponse::InternalServerError().finish(), } } #[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() .service(place_order) .route(EXPIRE_ORDERS.path(), web::post().to(expire_orders)) }) .bind(addr)? .run() .await }

encore run starts Postgres locally and delivers jobs to the subscription in the same process. Cron jobs do not run locally, so the expiry route is called by hand while testing:

~/orders
$ encore run$ curl -X POST localhost:4000/orders -H 'content-type: application/json' -d '{"email":"ada@example.com"}'order 3 placed12:04:32.100 │ receipt for order 3 sent to ada@example.com$ curl -X POST localhost:4000/internal/expire-ordersexpired 0 orders

On deploy, the topic and subscription become SNS and an SQS queue on AWS, or Pub/Sub on GCP, with the retry policy from the code, and the cron job runs once per environment on its schedule however many instances are running, all in your own account.

An agent adding a webhook job declares a subscription next to the handler and watches it run locally, and the queue appears in the cloud on the next deploy. Cloud access sits with Encore's IAM role, not the agent.

Good to know

send_email runs before the row is written, so a crash between the two can send a second receipt on retry. Passing the order ID as the email provider's idempotency key closes that gap. The expiry route is an ordinary HTTP route, so keep it harmless to call more than once.

Try Encore →

apalis with Redis

apalis is a Rust job library with Redis, Postgres and other backends. The worker runs next to the Actix server, and the handler pushes jobs into storage. This compiles against apalis 0.7:

use actix_web::{post, web, App, HttpResponse, HttpServer, Responder}; use apalis::prelude::*; use apalis_redis::RedisStorage; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize)] struct OrderPlaced { order_id: i64, email: String, } async fn send_receipt(job: OrderPlaced) -> Result<(), Error> { // Check a receipts table first: a retried job must not email twice. println!("receipt for order {} sent to {}", job.order_id, job.email); Ok(()) } #[post("/orders")] async fn place_order(storage: web::Data<RedisStorage<OrderPlaced>>) -> impl Responder { let mut storage = storage.get_ref().clone(); let job = OrderPlaced { order_id: 1, email: "ada@example.com".into() }; match storage.push(job).await { Ok(_) => HttpResponse::Accepted().finish(), Err(_) => HttpResponse::InternalServerError().finish(), } } #[tokio::main] async fn main() -> std::io::Result<()> { let conn = apalis_redis::connect("redis://127.0.0.1:6379").await.expect("redis"); let storage: RedisStorage<OrderPlaced> = RedisStorage::new(conn); let worker = WorkerBuilder::new("send-receipt") .backend(storage.clone()) .build_fn(send_receipt); let monitor = Monitor::new().register(worker); let data = web::Data::new(storage); let http = HttpServer::new(move || App::new().app_data(data.clone()).service(place_order)) .bind("0.0.0.0:8080")? .run(); tokio::select! { res = http => res, res = monitor.run() => res, } }

Good to know

Retries come from a retry layer added to the worker builder, and the apalis-cron crate adds scheduled jobs, which run in each process unless you coordinate them through storage. You run Redis locally, in CI and in production, and its persistence settings decide whether queued jobs survive a Redis restart. apalis's API has changed between minor releases, so pin the version.

tokio::spawn and tokio-cron-scheduler

The lightest option keeps everything in the process. tokio::spawn takes work off the request path, and tokio-cron-scheduler runs tasks on a cron expression, here version 0.15:

use tokio_cron_scheduler::{Job, JobScheduler, JobSchedulerError}; #[tokio::main] async fn main() -> Result<(), JobSchedulerError> { let scheduler = JobScheduler::new().await?; // Six fields: seconds first. Runs at minute 0 of every hour. scheduler .add(Job::new_async("0 0 * * * *", |_id, _scheduler| { Box::pin(async move { println!("expiring stale orders"); }) })?) .await?; scheduler.start().await?; tokio::signal::ctrl_c().await.ok(); Ok(()) }

Good to know

Spawned tasks disappear on restart, and with three instances the schedule runs three times an hour, so the job must either tolerate that or take a lock, such as a Postgres advisory lock, before working. Actix Web stops accepting connections on SIGTERM and waits for in-flight requests, as the server documentation describes, but spawned tasks are not part of that wait.

Retries in production

A queue retries a failing job, and a job that can never succeed will keep retrying, so set a maximum and send the rest to a dead-letter queue to inspect. Google Cloud Pub/Sub describes this as a retry policy with dead-letter topics, and SQS has the same through a redrive policy. With Encore, the retry limits come from max_retries and min_backoff in the code.

How to choose

  • Encore if you want durable jobs and scheduled tasks declared in the Actix Web code, running locally and on SNS and SQS or Pub/Sub in your AWS or GCP account.
  • apalis with Redis if you want a Rust job library and already run Redis.
  • tokio::spawn and tokio-cron-scheduler for best-effort work on a single instance.

Rust support in Encore is in beta, and you can sign up for access to try it with your Actix Web app.

Frequently asked questions

How do I run background jobs in Actix Web?

Publish a job to a durable queue from the handler, return 202, and process it in a worker that retries on failure and is safe to run twice. With Encore, declare static ORDER_PLACED: Topic<OrderPlaced> = Topic::new("order-placed"), publish with ORDER_PLACED.publish(&job).await, and register a #[encore::pubsub::register] static Subscription::new(&ORDER_PLACED, "send-receipt", send_receipt) with .max_retries(5). encore run delivers jobs locally, and on deploy the queue becomes SNS and SQS on AWS or Pub/Sub on GCP.

Is tokio::spawn enough for background work in Actix Web?

tokio::spawn runs a future on the runtime without blocking the response, but the work lives only in memory. A restart or deploy drops it, and nothing retries it, so it suits best-effort work rather than receipts, payments or webhooks.

How do I schedule a recurring task in Actix Web?

Use a scheduler that calls your code on a cron expression, either in process with tokio-cron-scheduler or from outside with a platform scheduler. With Encore, Job::new("expire-orders", "/internal/expire-orders", Schedule::Every("1h")) calls the route in deployed environments, and the handler is registered with .route(EXPIRE_ORDERS.path(), web::post().to(expire_orders)).

Why must background jobs be idempotent?

Queues such as SQS, Google Cloud Pub/Sub and Redis-backed job stores deliver at least once, so a job can run again after a timeout or crash. Record completed work under a unique key and check it before repeating a side effect such as an email.

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