Tokio Async in Rust: tokio::join! vs tokio::spawn()

Rust’s Tokio async runtime provides two common ways to execute multiple async tasks concurrently: tokio::join! and tokio::spawn(). Choosing the right one is crucial for efficiency and proper task management. In this article, we’ll break down their differences, advantages, and when to use each with examples.


Understanding tokio::join!

tokio::join! is used when you need structured concurrency—where all async tasks must complete together before moving on.

How It Works

  • Executes multiple futures concurrently in the same parent task.
  • If one task blocks, all other tasks wait.
  • Returns all results at once.

Example Usage

use tokio::join;

async fn task1() -> String {
    "Task 1 completed".to_string()
}

async fn task2() -> String {
    "Task 2 completed".to_string()
}

#[tokio::main]
async fn main() {
    let (res1, res2) = join!(task1(), task2());
    println!("{}\n{}", res1, res2);
}

Use case: When all tasks must complete before proceeding.


Understanding tokio::spawn()

tokio::spawn() runs each async function in a separate Tokio task, meaning they execute independently.

How It Works

  • Tasks run truly in parallel on Tokio’s scheduler.
  • If one task hangs or panics, others continue running.
  • Returns a JoinHandle<T>, which you must .await.

Example Usage

use reqwest::Client;
use tokio::task::JoinHandle;

const URLS: [&str; 3] = [
    "https://jsonplaceholder.typicode.com/todos/1",
    "https://jsonplaceholder.typicode.com/todos/2",
    "https://jsonplaceholder.typicode.com/todos/3",
];

#[tokio::main]
async fn main() {
    let client = Client::new();

    let handles: Vec<JoinHandle<()>> = URLS.iter()
        .map(|&url| {
            let client = client.clone();
            tokio::spawn(async move {
                if let Err(e) = fetch_url(&client, url).await {
                    eprintln!("Error fetching {}: {:?}", url, e);
                }
            })
        })
        .collect();

    for handle in handles {
        let _ = handle.await; // Ensure all tasks finish
    }
}

async fn fetch_url(client: &Client, url: &str) -> Result<(), reqwest::Error> {
    let res = client.get(url).send().await?;
    let content = res.text().await?;
    println!("{:?}", content);
    Ok(())
}

Use case: When tasks should run independently and in the background.


Comparison: tokio::join! vs tokio::spawn()

Featuretokio::join!tokio::spawn()
Task ExecutionRuns in the same parent taskRuns in separate Tokio tasks
Blocking BehaviorIf one task hangs, all are delayedIf one task hangs, others continue
Error HandlingIf one task panics, all are abortedIf one task panics, others keep running
Return ValuesReturns all task resultsReturns a JoinHandle<T> that you must .await
OverheadSlightly lower (same task)Slightly higher (separate tasks)
Use CaseWhen all tasks must complete togetherWhen tasks should run independently

Which One Should You Use?

  • Use tokio::join! when:
    • You need all tasks to finish together before moving forward.
    • Your tasks depend on each other.
    • Example: Fetch multiple API responses, then process them together.
  • Use tokio::spawn() when:
    • Each task should run independently.
    • A task may take longer, but others shouldn’t wait.
    • Example: Handling multiple user requests in a web server.

Final Thoughts

Both tokio::join! and tokio::spawn() have their strengths. Your choice depends on whether tasks should complete together (join!) or run independently (spawn()). Understanding their differences will help you write more efficient and scalable Rust async applications.

tokio::join! and tokio::spawn()
tokio::join! and tokio::spawn()