What is tokio task JoinSet

A JoinSet manages multiple async tasks and yields results as they complete.

When you need a group of tasks, not just one

You have a list of 50 URLs to fetch. You spawn a task for each one. Now you need the results. The naive approach collects JoinHandles in a Vec and loops over them, awaiting each one. That works, but it hides a timing trap. Task 3 might finish in 10 milliseconds. Task 1 takes 2 seconds. If you await the handles in order, you sit idle for two seconds waiting for Task 1, even though Task 3 has been screaming "I'm done!" since the start. You also lose the ability to cancel the whole batch if Task 1 crashes. You need a collection that hands you results the moment they arrive and lets you manage the group as a unit.

The finish line for async tasks

tokio::task::JoinSet is a collection for async tasks that manages the lifecycle of a group and yields results in completion order. Think of it as a finish line for a race. You send runners off (spawn tasks). You don't stand at the starting blocks checking each runner individually. You stand at the finish line. The moment any runner crosses, you grab their result. JoinSet tracks all the spawned tasks and gives you back the first one that finishes, no matter which one you spawned first. It also holds a cancel button for the whole group.

The set is reusable. It is not a one-shot future. You can spawn tasks, join them, spawn more, and join again. The set lives as long as you keep it in scope. This makes it ideal for loops where you process items in batches or continuously spawn work based on incoming data.

Minimal example

use tokio::task::JoinSet;

#[tokio::main]
async fn main() {
    // Create a set to hold tasks.
    let mut set = JoinSet::new();

    // Spawn tasks with different delays to simulate varying work.
    for i in 0..5 {
        set.spawn(async move {
            // Simulate work. Task 0 is slow, Task 4 is fast.
            let delay = if i == 0 { 100 } else { 10 };
            tokio::time::sleep(tokio::time::Duration::from_millis(delay)).await;
            i * 2
        });
    }

    // Collect results as they finish.
    while let Some(result) = set.join_next().await {
        match result {
            Ok(value) => println!("Got result: {}", value),
            Err(e) => eprintln!("Task failed: {}", e),
        }
    }
}

Lifecycle and behavior

JoinSet::new() creates an empty collection. spawn takes an async block and puts it in the set. The block must be Send and 'static by default. This matches the requirements of tokio::spawn. If you need to spawn non-Send tasks, JoinSet provides a spawn_local method for use with #[tokio::main(flavor = "current_thread")].

join_next is the core operation. It is an async function that suspends until any task in the set finishes. It returns Option<Result<T, JoinError>>.

  • Some(Ok(value)): A task completed successfully. value is the return type of the async block.
  • Some(Err(e)): A task panicked or was cancelled. e is a JoinError.
  • None: The set is empty. All tasks have been joined.

The while let loop drains the set. When join_next returns None, the loop exits. The set is now empty but still alive. You can call spawn again to add new tasks. This reusability is a key design choice. You don't need to create a new JoinSet for every batch. Reuse the same instance to reduce allocation overhead.

JoinError carries details about why a task failed. Call is_cancelled() to check if the task was aborted. Call is_panic() to check if the task panicked. If it panicked, into_panic() extracts the panic payload. This lets you distinguish between a graceful cancellation and a bug in your code.

Dropping a JoinSet cancels all remaining tasks. This is silent cancellation. No error is raised. The tasks are aborted, and the set is destroyed. If you drop the set without joining, you lose the results and you lose the ability to inspect errors. Keep the set alive until you have processed all outcomes.

Realistic pattern: batch processing with fail-fast

In production code, you often need to process a batch and stop if anything goes wrong. JoinSet makes this straightforward with abort_all.

use tokio::task::JoinSet;

/// Process a batch of items. Cancel everything if one task fails.
async fn process_batch(items: Vec<String>) -> Result<Vec<String>, String> {
    let mut set = JoinSet::new();

    for item in items {
        set.spawn(async move {
            // Simulate processing.
            process_one(item).await
        });
    }

    let mut results = Vec::new();

    while let Some(result) = set.join_next().await {
        match result {
            Ok(output) => results.push(output),
            Err(join_err) => {
                // A task panicked or was cancelled.
                // Cancel the rest of the batch immediately.
                set.abort_all();
                return Err(format!("Task failed: {}", join_err));
            }
        }
    }

    Ok(results)
}

async fn process_one(item: String) -> String {
    // Simulate work that might fail.
    if item == "bad" {
        panic!("Bad item");
    }
    item
}

abort_all cancels every task currently in the set. It does not remove them from the set. The tasks are still tracked. You must call join_next to reap the error results, or drop the set. If you drop the set after abort_all, the tasks are cleaned up, but you never see the JoinError values. If you need to log the failures, keep joining until the set is empty.

JoinSet also supports spawn_blocking. Use this when your batch includes CPU-bound work that would block the async runtime.

set.spawn_blocking(move || {
    // Heavy CPU work.
    compute_heavy(item)
});

spawn_blocking schedules the closure on a separate thread pool. The result is still collected via join_next. This lets you mix async and blocking tasks in the same batch without starving the runtime.

Pitfalls and compiler errors

Send bounds. JoinSet::spawn requires the future to be Send. If you capture an Rc in the async block, the compiler rejects the code with E0277 (trait bound not satisfied). Rc is not thread-safe. JoinSet runs tasks on a multi-threaded runtime by default. Use Arc instead.

// E0277: Rc is not Send.
use std::rc::Rc;
let data = Rc::new("shared");
set.spawn(async move {
    // Error: future cannot be sent between threads safely.
    println!("{}", data);
});

Fix this by using Arc.

use std::sync::Arc;
let data = Arc::new("shared");
set.spawn(async move {
    println!("{}", data);
});

Silent cancellation. Dropping a JoinSet cancels tasks without warning. If your tasks vanish and you wonder why, check your scope. The set might be going out of scope earlier than you expect. Bind the set to a variable that lives long enough to join all tasks.

Infinite loops with dynamic spawning. If you spawn tasks inside the join_next loop, be careful. If the spawn condition never becomes false, you create an infinite loop. Ensure your spawn logic terminates.

// Dangerous pattern.
while let Some(_) = set.join_next().await {
    // If this always spawns, the set never empties.
    set.spawn(async { /* ... */ });
}

Track the number of spawned tasks or use a flag to stop spawning.

Convention aside. The tokio community treats JoinSet as the standard for task groups. FuturesUnordered lives in the futures crate and adds a dependency. Use JoinSet unless you have a specific reason to avoid it. JoinSet is integrated with tokio's runtime and provides abort_all and spawn_blocking out of the box.

Treat abort_all as a fire drill. Call it, then reap the errors. Don't drop the set and hope for the best.

Decision: JoinSet vs alternatives

Use JoinSet when you spawn tasks dynamically and need results in completion order.

Use JoinSet when you need to cancel an entire batch of tasks with a single call via abort_all.

Use JoinSet when you want to mix async and blocking tasks in the same collection using spawn and spawn_blocking.

Reach for a Vec<JoinHandle> when you have a fixed, small number of tasks and can await them sequentially without performance loss.

Pick FuturesUnordered when you are polling futures that are already constructed and don't require spawning, or when you need to avoid Send requirements in a single-threaded runtime without using spawn_local.

Dropping a JoinSet cancels tasks silently. Manage your scope carefully.

Where to go next