Skip to content
rusthacks.dev
Architecture

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.

3 min read
Summarize in AI
Flat illustration for the post “Background Job Queues in Rust Compared by Trade Off”, composed around architecture.

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 channelPostgreSQL tableRedis list or streamBroker (RabbitMQ, SQS, Kafka)
Survives restartnoyesyes (with persistence)yes
Atomic with your datanoyes, same transactionnono (use an outbox)
Retries and delaysbuild itbuild itpartly built inbuilt in
Throughputhighestmoderatehighhigh to very high
New infrastructurenonenoneRedisthe broker
Fan-out to many consumersbuild itawkwardstreams: yesyes
Fitbest-effort workmost servicescaches plus light queuesmany services, high volume

Option 1: in-process

rust
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

migrations/0006_jobs.sqlSQL
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';
rust
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.

Share

Keep reading

Get new posts by email

We send one email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.