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. Later on in the article well use JoinSet


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.

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()

Improved version :

code flow
[dependencies]
reqwest = { version = "0.12.15", features = ["json"] }
serde = { version = "1.0.219", features = ["derive"] }
tokio = { version = "1.44.1", features = ["macros", "rt-multi-thread"] }

main.rs

use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::error::Error;
use tokio::task::JoinSet;

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

#[derive(Debug, Serialize, Deserialize)]
struct Todo {
    id: i32,
    title: String,
    completed: bool,
}

async fn fetch(url: &str, client: &Client) -> Result<Todo, Box<dyn Error + Send + Sync>> {
    let response = client.get(url).send().await?;

    if !response.status().is_success() {
        return Err(format!("Error: HTTP status {}", response.status()).into());
    }

    let todo = response.json::<Todo>().await?;
    println!("Fetched todo #{}: {}", todo.id, todo.title);
    Ok(todo)
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn Error + Send + Sync>> {
    let client = Client::new();
    let mut tasks = JoinSet::new();

    for &url in &URLS {
        let client = client.clone();
        tasks.spawn(async move { fetch(url, &client).await });
    }

    let mut successful_todos = Vec::new();
    while let Some(result) = tasks.join_next().await {
        match result {
            Ok(Ok(todo)) => successful_todos.push(todo),
            Ok(Err(e)) => eprintln!("Error fetching todo: {}", e),
            Err(e) => eprintln!("Task panicked: {}", e),
        }
    }

    println!("\nSuccessfully fetched {} todos", successful_todos.len());

    Ok(())
}
spawn tasks
tasks.spawn and tasks.join_next – note the “for loop” for the spawn, and while for the join

Ok inside Ok? wtf?

Ok inside Ok

Can’t I just use tokio::join; ?

You can use tokio::join! instead of tokio::task::JoinSet, but there’s a key limitation:

🔹 tokio::join! requires knowing the number of tasks at compile time

  • Since join! takes a fixed number of futures, you can’t use it in a loop to dynamically spawn tasks.
  • This makes it impractical if you have an unknown or changing number of URLs.
async
Async

Tip

When using task::spawn inside map don’t have the semi colon!

no semi colon