How to Build a Task Queue in Rust

Build a Rust task queue using trpl::spawn_task to enqueue jobs and trpl::join to process them concurrently.

How to Build a Task Queue in Rust

You're building a chat server. A user sends a message. You need to save it to the database, push a notification to their phone, and update the in-memory cache. If you run those one after another, the user stares at a loading spinner. If you spin up a new OS thread for every message, the server crashes under load because threads are expensive. You need a way to juggle hundreds of these little jobs without paying the cost of hundreds of threads. That's a task queue. In Rust, you build it with async tasks.

The kitchen analogy

Think of a busy restaurant kitchen. The chefs are your CPU cores. The orders are your tasks. In a traditional thread-per-request model, you hire a new waiter for every customer. If the restaurant gets full, you run out of waiters, or you spend all your money paying waiters who are just standing around waiting for the oven.

An async task queue works differently. You have a few waiters (the executor) who carry a stack of tickets. When a waiter drops an order at the oven, they don't stand there waiting. They grab another ticket and run. When the oven dings, they come back and finish the job. The tickets are tasks. The waiters are the runtime. The oven dinging is an await point. Rust's task queue lets you run thousands of tickets with just a handful of waiters. The runtime schedules the work. You provide the logic. It handles the juggling.

Minimal example

The core building blocks are spawn_task and join. spawn_task creates a task and hands you a handle. join runs multiple async blocks at once and waits for all of them.

use std::time::Duration;

/// Demonstrates spawning a task and joining it with other work.
fn main() {
    // block_on starts the async runtime and runs the top-level future.
    trpl::block_on(async {
        // spawn_task creates a new task and returns a handle immediately.
        // The task runs concurrently with the rest of this block.
        let handle = trpl::spawn_task(async {
            for i in 1..10 {
                println!("Task {i} running");
                // sleep yields control back to the executor.
                // This is where the runtime can switch to other tasks.
                trpl::sleep(Duration::from_millis(500)).await;
            }
        });

        // join runs two async blocks concurrently.
        // It returns a tuple of results when both finish.
        trpl::join(
            async { println!("Other work"); },
            async { handle.await.unwrap(); }
        ).await;
    });
}

The spawn_task call returns instantly. The loop inside the task starts running, but it pauses at sleep. The runtime sees that pause and switches to the join block. The join block runs "Other work" and waits for the handle. When the sleep finishes, the task resumes. This cycle repeats until the loop ends.

You write the tasks. The runtime plays the orchestra.

Walkthrough of execution

Here is what happens step by step when the code runs.

  1. block_on initializes the executor. The executor is the loop that polls tasks.
  2. spawn_task creates a future and registers it with the executor. It returns a JoinHandle. The task is now owned by the executor, not your local scope.
  3. join receives two futures. It polls the first one.
  4. The first future prints "Other work" and completes.
  5. join polls the second future, which is handle.await.
  6. The handle checks the spawned task. The task is running the loop.
  7. The task prints "Task 1 running" and hits sleep.
  8. sleep returns Pending. The task yields control.
  9. The executor notes that the task is waiting for a timer. It switches to other work if available.
  10. When the timer fires, the executor marks the task as ready.
  11. The task resumes, prints "Task 2 running", and yields again.
  12. This repeats until the loop finishes.
  13. The task completes. The handle resolves with the result.
  14. join sees both futures are done and returns.
  15. block_on exits.

The key insight is that await is not a blocking call. It suspends the current task and returns control to the executor. The executor decides when to resume. This is why you can run thousands of tasks. They share the same threads. They take turns.

Realistic example

In real code, you often spawn tasks dynamically. You might receive a list of items and process each one concurrently. You collect the handles and await them later.

use std::time::Duration;

/// Processes a batch of items concurrently using a task queue.
fn main() {
    trpl::block_on(async {
        let items = vec!["item-1", "item-2", "item-3"];

        // Collect handles to wait for all tasks later.
        let mut handles = Vec::new();

        for item in items {
            // spawn_task requires the closure to be 'static.
            // We move ownership of item into the task.
            let handle = trpl::spawn_task(async move {
                // Simulate async work like a database query.
                trpl::sleep(Duration::from_millis(100)).await;
                format!("Processed {}", item)
            });
            handles.push(handle);
        }

        // Await each handle to collect results.
        // This waits for tasks in order, but they all ran concurrently.
        for handle in handles {
            // handle.await returns Result<String, JoinError>.
            // unwrap panics if the task was cancelled or panicked.
            let result = handle.await.unwrap();
            println!("{}", result);
        }
    });
}

The async move block is crucial. It moves the item variable into the task. The task owns the data. The task might run on a different thread than the one that spawned it. It might outlive the current scope. Rust requires the task to be 'static, meaning it cannot hold references to local variables. It must own everything it uses.

Convention aside: In production code, use handle.await.expect("task failed") instead of unwrap. The message helps debugging. In examples, unwrap keeps the noise down.

Pitfalls and compiler errors

Task queues introduce new failure modes. The compiler catches many of them, but you need to know what to look for.

Blocking the executor

The biggest mistake is using blocking operations inside an async task. If you call std::thread::sleep or run a heavy CPU loop without yielding, you freeze the waiter. The runtime thinks the task is stuck. Other tasks starve because the thread is occupied.

Use trpl::sleep instead of std::thread::sleep. Use async-compatible libraries for I/O. If you must run blocking code, isolate it. Many runtimes provide spawn_blocking for CPU-bound work. That spawns the work on a separate thread pool so it doesn't block the async executor.

Never block the executor. A blocked task is a frozen ticket.

Ownership and moves

When you spawn a task, you move data into it. If you try to use the data after spawning, the compiler rejects you with E0382 (use of moved value).

let data = String::from("hello");
let _handle = trpl::spawn_task(async move {
    println!("{}", data);
});
// Error: E0382 use of moved value `data`
// println!("{}", data);

The task owns data. You can't use it here. If you need to share data, use reference counting like Rc or Arc.

Static lifetime requirements

Tasks must be 'static. You cannot pass a reference to a local variable into a task. The task might run after the variable is dropped.

let data = String::from("hello");
// Error: E0373 closure may outlive the current function
// trpl::spawn_task(async {
//     println!("{}", data);
// });

The closure captures data by reference. The task could outlive data. The compiler stops you. Fix this by moving the data into the task with async move, or by cloning it into an Arc if multiple tasks need it.

Trust the borrow checker on spawn. If it rejects a reference, the task would crash at runtime.

Send bounds

If your runtime is multi-threaded, tasks must be Send. This means the task can be moved between threads. If you try to spawn a non-Send task, you get E0277 (trait bound not satisfied).

// Error: E0277 the trait `Send` is not implemented
// trpl::spawn_task(async {
//     let data = std::rc::Rc::new(42);
// });

Rc is not Send. It uses reference counting without atomic operations. Use Arc instead. Arc is thread-safe.

Convention aside: Name your handles clearly. let db_handle = spawn_task(...) is better than let h = spawn_task(...). Handles are remote controls for tasks. Name them after the job they control.

Decision matrix

Pick the primitive that matches your control flow.

Use spawn_task when you need to start a background job and get a handle back immediately. Use join when you want to run two or more async blocks concurrently and wait for the group. Use handle.await when you have a handle from spawn_task and need to pause until that specific task finishes. Reach for channels when tasks need to exchange messages dynamically, not just return a single value. Reach for Arc when multiple tasks need to share read-only data across threads. Reach for Mutex or RwLock when tasks need to mutate shared state safely.

Pick the tool that fits the shape of your data flow. Spawn for fire-and-forget. Join for parallel groups. Await for results.

Where to go next