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()
Feature | tokio::join! | tokio::spawn() |
---|---|---|
Task Execution | Runs in the same parent task | Runs in separate Tokio tasks |
Blocking Behavior | If one task hangs, all are delayed | If one task hangs, others continue |
Error Handling | If one task panics, all are aborted | If one task panics, others keep running |
Return Values | Returns all task results | Returns a JoinHandle<T> that you must .await |
Overhead | Slightly lower (same task) | Slightly higher (separate tasks) |
Use Case | When all tasks must complete together | When 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()
Improved version :

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

Ok inside Ok? wtf?

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.

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