Spawning Tasks With tokio spawn and Joining Their Results
Spawn tasks with tokio::spawn, wait for them with JoinHandle and collect results with JoinSet. Includes the Send and static bounds and the panics join returns.

tokio::spawn turns a future into a task that the runtime runs on its own, and returns a JoinHandle through which you wait for the result. Awaiting the handle is how you get the value back, how you find out the task panicked, and how you cancel it. The handle has three behaviours that surprise people: dropping it does not cancel the task, the result is wrapped in a Result even when your task cannot fail, and the tasks start running before you ever await them. This post demonstrates each, then covers fan-out patterns and where a plain join! is the better tool. Everything compiled on Rust 1.99, edition 2024, with tokio 1.
Spawn, then join
use tokio::time::{sleep, Duration, Instant};
async fn work(id: u32, millis: u64) -> u32 {
sleep(Duration::from_millis(millis)).await;
id * 10
}
#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() {
let t = Instant::now();
let a = tokio::spawn(work(1, 300));
let b = tokio::spawn(work(2, 200));
let c = tokio::spawn(work(3, 100));
println!("spawned, elapsed {:?}", t.elapsed());
let results = (a.await.unwrap(), b.await.unwrap(), c.await.unwrap());
println!("{results:?} after {:?}", t.elapsed());
}spawned, elapsed 0ns
(10, 20, 30) after 300msThe three tasks ran concurrently, so the total is the longest (300 ms), not the sum. The handle's type is JoinHandle<u32>, and awaiting it gives Result<u32, JoinError>. The unwrap here is the question of the next section.
Why the result is a Result: panics and cancellation
A task can end in three ways: it returns a value, it panics, or something aborts it. The first is Ok; the other two are Err(JoinError), which tells you which one it was. A panic inside a spawned task does not take down the program or the runtime; it is caught at the task boundary.
use tokio::time::{sleep, Duration};
#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() {
let ok = tokio::spawn(async { 42 });
let boom = tokio::spawn(async {
let v: Vec<u32> = Vec::new();
v[3]
});
let slow = tokio::spawn(async {
sleep(Duration::from_secs(3600)).await;
"never"
});
slow.abort();
println!("ok: {:?}", ok.await);
match boom.await {
Ok(v) => println!("boom: {v}"),
Err(e) if e.is_panic() => println!("boom: panicked"),
Err(e) => println!("boom: {e}"),
}
match slow.await {
Err(e) if e.is_cancelled() => println!("slow: cancelled"),
other => println!("slow: {other:?}"),
}
}ok: Ok(42)
boom: panicked
slow: cancelledThe panic message still goes to stderr through the panic hook (it is not part of the block above), so a service logs it even when nobody awaits the handle. A service that spawns a task per request survives one request panicking: the other tasks keep running and the failed one reports itself to whoever awaits it. If nobody awaits it, the panic is invisible apart from that stderr line, which is one of the traps in the next section. To recover the panic's payload, call e.into_panic(), which returns the Box<dyn Any + Send> discussed in Any and downcasting.
Dropping the handle detaches the task
A JavaScript promise or a Go goroutine has no handle to lose. A Tokio task does, and the intuitive guess, that dropping the handle cancels the task, is wrong. The task keeps running to completion in the background:
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use tokio::time::{sleep, Duration};
#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() {
let finished = Arc::new(AtomicBool::new(false));
let flag = finished.clone();
let handle = tokio::spawn(async move {
sleep(Duration::from_millis(100)).await;
flag.store(true, Ordering::SeqCst);
});
drop(handle);
println!("after drop, finished = {}", finished.load(Ordering::SeqCst));
sleep(Duration::from_millis(200)).await;
println!("later, finished = {}", finished.load(Ordering::SeqCst));
}after drop, finished = false
later, finished = trueDetaching is a feature when you want fire-and-forget work, and a leak when you did not mean it. The task also cannot be waited for at shutdown if you no longer hold the handle. To cancel on drop, wrap the handle in a guard that calls abort() in its Drop, or use a JoinSet, which aborts its tasks when it is dropped (see JoinSet). Contrast this with dropping a plain future, which cancels it, as explained in cancelling tasks.
Fan out and collect
To run N operations concurrently and collect the results in order, spawn them into a vector and await the handles in sequence. The awaits are sequential but the work is not, so total time is the slowest task:
use tokio::time::{sleep, Duration, Instant};
async fn square_after(n: u64) -> u64 {
sleep(Duration::from_millis(100 * (5 - n))).await;
n * n
}
#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() {
let t = Instant::now();
let handles: Vec<_> = (1..=4).map(|n| tokio::spawn(square_after(n))).collect();
let mut out = Vec::new();
for h in handles {
out.push(h.await.unwrap());
}
println!("{out:?} in {:?}", t.elapsed());
let t = Instant::now();
let joined = futures::future::join_all((1..=4).map(square_after)).await;
println!("{joined:?} in {:?}", t.elapsed());
}[1, 4, 9, 16] in 400ms
[1, 4, 9, 16] in 400msBoth give the same answer in the same time. The output order follows the order of the handles (input order), not completion order: task 4 finishes first, and is still reported last. join_all has no tasks, only one future polling many, so everything runs on the calling task and no thread parallelism is possible. For I/O waiting that is perfectly fine; for CPU work between awaits, spawned tasks can run on other cores and join_all cannot.
Spawn or join!?
tokio::spawn | join! / join_all | |
|---|---|---|
| Runs on | Its own task, any worker thread | The current task |
| Future must be | Send + 'static | Anything, can borrow locals |
| Allocation | One per task | None (join!) or one vector (join_all) |
| A panic | Caught, returned as JoinError | Propagates to the caller |
| Cancelled when the caller is dropped | No | Yes |
| Parallelism across cores | Yes | No |
Pick join! when the futures borrow local data, you want them to share the caller's fate, and the work is waiting. Pick spawn when the work must outlive the caller, run in parallel, or survive a panic in a sibling. The 'static requirement is the price of spawning, and it is explained at length in why spawn needs a static future. Spawning has a price: one million empty tasks, spawned and joined on a four-core Apple M1 Max in a release build with the default multi-thread runtime, took between 248 and 305 milliseconds across three rounds, roughly 250 to 300 nanoseconds per task including the allocation and the join. That does not matter for a request handler. It does matter if you spawn once per element of a ten-million-row loop; there, batch the elements into chunks and spawn per chunk.
Returning errors from tasks
When a task returns Result, awaiting the handle gives a nested result, Result<Result<T, E>, JoinError>. Flatten it right at the join so the rest of the code sees one error type:
use std::fmt;
use tokio::task::JoinError;
#[derive(Debug)]
enum AppError {
Parse(std::num::ParseIntError),
Task(JoinError),
}
impl fmt::Display for AppError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
AppError::Parse(e) => write!(f, "parse: {e}"),
AppError::Task(e) => write!(f, "task: {e}"),
}
}
}
impl From<JoinError> for AppError {
fn from(e: JoinError) -> Self {
AppError::Task(e)
}
}
impl From<std::num::ParseIntError> for AppError {
fn from(e: std::num::ParseIntError) -> Self {
AppError::Parse(e)
}
}
async fn parse_in_task(s: &'static str) -> Result<i32, AppError> {
let n = tokio::spawn(async move { s.parse::<i32>() }).await??;
Ok(n)
}
#[tokio::main(flavor = "current_thread")]
async fn main() {
println!("{:?}", parse_in_task("12").await.ok());
match parse_in_task("twelve").await {
Ok(_) => {}
Err(e) => println!("error: {e}"),
}
}Some(12)
error: parse: invalid digit found in stringThe double question mark (.await??) unwraps the join result and then the task's own result, each converted by From. The conversion machinery is explained in From and Into, and handling groups of tasks without writing this plumbing is what JoinSet is for.
Cancelling a task you hold a handle to
Three methods stop a task from the outside, and they differ in what happens to its in-flight work. handle.abort() marks the task for cancellation; the next time the runtime would poll it, it is dropped instead, and any .await on the handle returns Err(JoinError) with is_cancelled() true. A task blocked in a synchronous call cannot be aborted until the call returns, and a task that has already finished is unaffected, so abort is a request and not a guarantee about timing. abort_handle() gives you a second, clonable handle that can only abort, useful when another component needs the right to cancel without owning the result. And dropping all senders of a channel the task is waiting on usually makes the task end by itself, which is the cleaner way to stop a worker that has cleanup to do.
Always decide who is responsible for ending each spawned task before you write the spawn. Tasks that nobody awaits and nobody can cancel are how a service accumulates work that outlives its purpose. The bookkeeping tool is JoinSet, and the shutdown story is in graceful shutdown.
What spawning does not give you
It does not bound concurrency: a loop that spawns 100,000 tasks creates 100,000 tasks, each holding its future's memory, and a limit needs a semaphore (semaphore). It does not give structured lifetimes: a spawned task is not tied to the scope that created it, which is why the language has no safe way to let a spawned task borrow from its parent. And it does not guarantee a start order or a thread. If a test depends on tasks running in the order you spawned them, use a current-thread runtime and say so in the test.


