Background Job Queues in Rust Compared by Trade Off
Pick a background job system for a Rust service: an in-process Tokio channel, a database table with SKIP LOCKED, Redis queues, or a broker. Compare durability, retries, scale and operational cost.

Some work should not happen inside the request: sending email, resizing an image, calling a slow API. The question is where the pending work lives while it waits. That choice decides what happens on a crash, how you retry and how much you operate. Everything compiles on Rust 1.90 with edition 2024, tokio 1 and sqlx 0.8; the descriptions of external systems reflect their documented behavior.
The decision table
| In-process channel | PostgreSQL table | Redis list or stream | Broker (RabbitMQ, SQS, Kafka) | |
|---|---|---|---|---|
| Survives restart | no | yes | yes (with persistence) | yes |
| Atomic with your data | no | yes, same transaction | no | no (use an outbox) |
| Retries and delays | build it | build it | partly built in | built in |
| Throughput | highest | moderate | high | high to very high |
| New infrastructure | none | none | Redis | the broker |
| Fan-out to many consumers | build it | awkward | streams: yes | yes |
| Fit | best-effort work | most services | caches plus light queues | many services, high volume |
Option 1: in-process
use tokio::sync::mpsc;
#[derive(Debug)]
struct Job {
email: String,
}
#[tokio::main]
async fn main() {
let (tx, mut rx) = mpsc::channel::<Job>(1024);
let worker = tokio::spawn(async move {
while let Some(job) = rx.recv().await {
println!("sending welcome to {}", job.email);
}
});
tx.send(Job { email: "[email protected]".into() }).await.unwrap();
drop(tx);
worker.await.unwrap();
}A bounded channel gives backpressure (bounded channels). On deploy or crash, queued jobs vanish. Acceptable for cache warming, metrics flushes and anything the user can retry.
Option 2: a PostgreSQL queue
CREATE TABLE jobs (
id BIGSERIAL PRIMARY KEY,
kind TEXT NOT NULL,
payload JSONB NOT NULL,
run_at TIMESTAMPTZ NOT NULL DEFAULT now(),
attempts INT NOT NULL DEFAULT 0,
max_attempts INT NOT NULL DEFAULT 5,
state TEXT NOT NULL DEFAULT 'queued',
last_error TEXT
);
CREATE INDEX jobs_ready ON jobs (run_at) WHERE state = 'queued';use sqlx::PgPool;
pub async fn take_one(pool: &PgPool) -> sqlx::Result<Option<(i64, String, serde_json::Value, i32)>> {
sqlx::query_as(
"UPDATE jobs SET state = 'running', attempts = attempts + 1
WHERE id = (
SELECT id FROM jobs
WHERE state = 'queued' AND run_at <= now()
ORDER BY run_at
FOR UPDATE SKIP LOCKED
LIMIT 1
)
RETURNING id, kind, payload, attempts",
)
.fetch_optional(pool)
.await
}
pub async fn finish(pool: &PgPool, id: i64, result: Result<(), String>, attempts: i32) -> sqlx::Result<()> {
match result {
Ok(()) => {
sqlx::query("UPDATE jobs SET state = 'done' WHERE id = $1").bind(id).execute(pool).await?;
}
Err(e) if attempts >= 5 => {
sqlx::query("UPDATE jobs SET state = 'dead', last_error = $2 WHERE id = $1")
.bind(id).bind(e).execute(pool).await?;
}
Err(e) => {
sqlx::query(
"UPDATE jobs SET state = 'queued', last_error = $2,
run_at = now() + (interval '5 seconds' * power(2, attempts)) WHERE id = $1",
)
.bind(id).bind(e).execute(pool).await?;
}
}
Ok(())
}SKIP LOCKED lets many workers pull different jobs. Retries use exponential backoff by rescheduling run_at. A worker crash leaves a job running: add a lease (locked_until) and requeue expired ones. The big advantage is enqueueing in the same transaction as the business write (outbox pattern). Crates such as sqlxmq and apalis package this design; evaluate their maintenance before adopting.
Option 3: Redis
A list with LPUSH/BRPOP is simple; streams with consumer groups give acknowledgements and redelivery. Persistence depends on your Redis configuration, and a job taken but not acknowledged needs a reaper. Good when you already run Redis and want speed (Redis).
Option 4: a broker
RabbitMQ, SQS and Kafka provide durable queues, retries, dead letters and many consumers. They add a system to run and monitor, and a second place that must agree with your database (use the outbox). Pick one when several services consume the same events, or throughput exceeds what a database queue should carry.
Rules for any queue
1. **Make handlers idempotent:** a job may run twice (idempotency keys). 2. **Bound retries and keep failures visible:** a dead-letter state with an alert. 3. **Set a timeout per job** so a stuck handler does not hold a worker. 4. **Shut down gracefully:** stop taking jobs, finish the current one (graceful shutdown). 5. **Measure queue depth and age of the oldest job;** alert on both.
Start with the PostgreSQL table if you have a database and need durability; move up when the numbers say so.


